@mulmobridge/chat-service 1.2.0 → 1.3.1

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/relay.d.ts CHANGED
@@ -34,6 +34,8 @@ export interface RelayDeps {
34
34
  getRole: (roleId: string) => Role;
35
35
  defaultRoleId: string;
36
36
  logger: Logger;
37
+ /** Monotonic milliseconds for the reply limit. Defaults to `performance.now()`. */
38
+ now?: () => number;
37
39
  }
38
40
  export declare function createRelay(deps: RelayDeps): RelayFn;
39
41
  export declare function resolveDefaultRole(bridgeOptions: Readonly<Record<string, string | number | boolean>> | undefined, getRole: (roleId: string) => Role, fallbackRoleId: string, logger: Logger, transportId: string): string;
package/dist/relay.js CHANGED
@@ -6,10 +6,9 @@
6
6
  // point, session events, role lookup, logger) arrive through
7
7
  // `createRelay(deps)` so the module has no direct imports from the
8
8
  // host.
9
- import { EVENT_TYPES } from "@mulmobridge/protocol";
9
+ import { EVENT_TYPES, resolveReplyTimeoutMs } from "@mulmobridge/protocol";
10
10
  import { createKeyedSerializer } from "./keyed-serializer.js";
11
- // ── Constants ────────────────────────────────────────────────
12
- const REPLY_TIMEOUT_MS = 5 * 60 * 1000;
11
+ import { remainingReplyMs } from "./reply-deadline.js";
13
12
  // ── Factory ──────────────────────────────────────────────────
14
13
  export function createRelay(deps) {
15
14
  const serialize = createKeyedSerializer();
@@ -19,10 +18,19 @@ export function createRelay(deps) {
19
18
  // sessions, and split the conversation across them (#1878). The
20
19
  // JSON pair is an unambiguous composite key for the two ids.
21
20
  const key = JSON.stringify([params.transportId, params.externalChatId]);
22
- return serialize.run(key, () => processRelayMessage(deps, params));
21
+ const budget = {
22
+ receivedAtMs: clockOf(deps)(),
23
+ replyTimeoutMs: resolveBridgeReplyTimeout(params.bridgeOptions, deps.logger, params.transportId),
24
+ };
25
+ return serialize.run(key, () => processRelayMessage(deps, params, budget));
23
26
  };
24
27
  }
25
- async function processRelayMessage(deps, params) {
28
+ // Neutral about the cause: a queue behind an earlier message, or slow setup on an idle chat.
29
+ const EXPIRED_BEFORE_START_REPLY = "The request timed out before the agent could start on it. Please send it again.";
30
+ // Monotonic by default, so a wall-clock step between receipt and processing cannot move the limit.
31
+ const clockOf = (deps) => deps.now ?? (() => performance.now());
32
+ const remainingOf = (budget, deps) => remainingReplyMs(budget.receivedAtMs, budget.replyTimeoutMs, clockOf(deps)());
33
+ async function processRelayMessage(deps, params, budget) {
26
34
  const { store, handleCommand, startChat, onSessionEvent, getRole, defaultRoleId, logger } = deps;
27
35
  const { transportId, externalChatId, attachments, bridgeOptions } = params;
28
36
  let { text } = params;
@@ -62,6 +70,12 @@ async function processRelayMessage(deps, params) {
62
70
  chatState = commandResult.nextState;
63
71
  text = commandResult.forwardAs;
64
72
  }
73
+ // Checked here, not on entry: a command above still runs and answers at once, but an agent turn
74
+ // started after the limit would produce a reply nobody is waiting for.
75
+ if (remainingOf(budget, deps) === 0) {
76
+ logger.info("chat-service", "message expired before its turn started", { transportId, externalChatId });
77
+ return { kind: "ok", reply: EXPIRED_BEFORE_START_REPLY };
78
+ }
65
79
  const result = await startChat({
66
80
  message: text,
67
81
  roleId: chatState.roleId,
@@ -96,7 +110,7 @@ async function processRelayMessage(deps, params) {
96
110
  };
97
111
  }
98
112
  try {
99
- const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, params.onChunk);
113
+ const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, remainingOf(budget, deps), params.onChunk);
100
114
  await store.setChatState(transportId, {
101
115
  ...chatState,
102
116
  updatedAt: new Date().toISOString(),
@@ -139,15 +153,23 @@ export function resolveDefaultRole(bridgeOptions, getRole, fallbackRoleId, logge
139
153
  }
140
154
  return resolved.id;
141
155
  }
156
+ // The bridge client reads the same option for its ack timer, so a bad value
157
+ // is warned about on both ends and both fall back to the same default.
158
+ function resolveBridgeReplyTimeout(bridgeOptions, logger, transportId) {
159
+ const { replyTimeoutMs, warning } = resolveReplyTimeoutMs(bridgeOptions?.replyTimeoutMs);
160
+ if (warning)
161
+ logger.warn("chat-service", "bridge reply timeout option ignored", { transportId, warning });
162
+ return replyTimeoutMs;
163
+ }
142
164
  // Kept out of the factory closure so future packaging doesn't need
143
165
  // to re-capture anything; `onSessionEvent` arrives as a plain param.
144
- function collectAgentReply(onSessionEvent, chatSessionId, onChunk) {
166
+ function collectAgentReply(onSessionEvent, chatSessionId, replyTimeoutMs, onChunk) {
145
167
  return new Promise((resolve) => {
146
168
  const textChunks = [];
147
169
  const timer = setTimeout(() => {
148
170
  unsubscribe();
149
171
  resolve(textChunks.join("") || "The request timed out before a reply was generated.");
150
- }, REPLY_TIMEOUT_MS);
172
+ }, replyTimeoutMs);
151
173
  const unsubscribe = onSessionEvent(chatSessionId, (event) => {
152
174
  const type = event.type;
153
175
  if (type === EVENT_TYPES.text) {
@@ -0,0 +1,2 @@
1
+ /** Milliseconds of the reply limit still left at `nowMs`; never negative. */
2
+ export declare function remainingReplyMs(receivedAtMs: number, replyTimeoutMs: number, nowMs: number): number;
@@ -0,0 +1,9 @@
1
+ // A turn's reply limit is counted from when the relay RECEIVED the message,
2
+ // not from when the turn reached the front of its chat's queue. The bridge
3
+ // client starts its ack timer when it sends, so a limit counted from the start
4
+ // of collection let a queued or slow-to-start turn outlive the client's wait
5
+ // and have its reply dropped (#3312).
6
+ /** Milliseconds of the reply limit still left at `nowMs`; never negative. */
7
+ export function remainingReplyMs(receivedAtMs, replyTimeoutMs, nowMs) {
8
+ return Math.max(0, receivedAtMs + replyTimeoutMs - nowMs);
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/chat-service",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Server-side chat service for MulmoBridge — socket.io + REST bridge to Claude Code agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,14 +20,14 @@
20
20
  "scripts": {
21
21
  "build": "tsc",
22
22
  "prepack": "yarn build",
23
- "typecheck": "tsc --noEmit",
23
+ "typecheck": "tsc -p tsconfig.typecheck.json",
24
24
  "test": "tsx --test test/test_*.ts",
25
25
  "lint": "eslint src test"
26
26
  },
27
27
  "license": "MIT",
28
28
  "author": "Receptron Team",
29
29
  "dependencies": {
30
- "@mulmobridge/protocol": "^1.0.1"
30
+ "@mulmobridge/protocol": "^1.1.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "express": "^5.0.0",