auq-mcp-server 2.2.2 → 2.4.0

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.
Files changed (62) hide show
  1. package/README.md +82 -0
  2. package/dist/bin/auq.js +45 -39
  3. package/dist/bin/tui-app.js +78 -8
  4. package/dist/package.json +1 -1
  5. package/dist/src/__tests__/server.abort.test.js +214 -0
  6. package/dist/src/cli/commands/__tests__/answer.test.js +199 -0
  7. package/dist/src/cli/commands/__tests__/config.test.js +218 -0
  8. package/dist/src/cli/commands/__tests__/sessions.test.js +282 -0
  9. package/dist/src/cli/commands/answer.js +128 -0
  10. package/dist/src/cli/commands/config.js +263 -0
  11. package/dist/src/cli/commands/sessions.js +164 -0
  12. package/dist/src/cli/utils.js +95 -0
  13. package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
  14. package/dist/src/config/defaults.js +3 -0
  15. package/dist/src/config/types.js +4 -0
  16. package/dist/src/core/ask-user-questions.js +3 -2
  17. package/dist/src/i18n/locales/en.js +8 -1
  18. package/dist/src/i18n/locales/ko.js +8 -1
  19. package/dist/src/server.js +64 -11
  20. package/dist/src/session/SessionManager.js +69 -4
  21. package/dist/src/session/__tests__/SessionManager.test.js +65 -0
  22. package/dist/src/tui/ThemeProvider.js +2 -1
  23. package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
  24. package/dist/src/tui/components/ConfirmationDialog.js +5 -4
  25. package/dist/src/tui/components/Footer.js +24 -23
  26. package/dist/src/tui/components/ReviewScreen.js +2 -1
  27. package/dist/src/tui/components/SessionDots.js +33 -4
  28. package/dist/src/tui/components/SessionPicker.js +27 -18
  29. package/dist/src/tui/components/Spinner.js +19 -0
  30. package/dist/src/tui/components/StepperView.js +71 -7
  31. package/dist/src/tui/components/WaitingScreen.js +2 -1
  32. package/dist/src/tui/components/__tests__/ConfirmationDialog.test.js +134 -0
  33. package/dist/src/tui/components/__tests__/Footer.test.js +121 -0
  34. package/dist/src/tui/components/__tests__/ReviewScreen.test.js +89 -0
  35. package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
  36. package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
  37. package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
  38. package/dist/src/tui/components/__tests__/StepperView.keyboard.test.js +135 -0
  39. package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
  40. package/dist/src/tui/components/__tests__/WaitingScreen.test.js +60 -0
  41. package/dist/src/tui/constants/keybindings.js +40 -0
  42. package/dist/src/tui/session-watcher.js +50 -0
  43. package/dist/src/tui/themes/catppuccin-latte.js +7 -0
  44. package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
  45. package/dist/src/tui/themes/dark.js +7 -0
  46. package/dist/src/tui/themes/dracula.js +7 -0
  47. package/dist/src/tui/themes/github-dark.js +7 -0
  48. package/dist/src/tui/themes/github-light.js +7 -0
  49. package/dist/src/tui/themes/gruvbox-dark.js +7 -0
  50. package/dist/src/tui/themes/gruvbox-light.js +7 -0
  51. package/dist/src/tui/themes/light.js +7 -0
  52. package/dist/src/tui/themes/monokai.js +7 -0
  53. package/dist/src/tui/themes/nord.js +7 -0
  54. package/dist/src/tui/themes/one-dark.js +7 -0
  55. package/dist/src/tui/themes/rose-pine.js +7 -0
  56. package/dist/src/tui/themes/solarized-dark.js +7 -0
  57. package/dist/src/tui/themes/solarized-light.js +7 -0
  58. package/dist/src/tui/themes/tokyo-night.js +7 -0
  59. package/dist/src/tui/utils/__tests__/detectTheme.test.js +78 -0
  60. package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
  61. package/dist/src/tui/utils/staleDetection.js +51 -0
  62. package/package.json +1 -1
package/README.md CHANGED
@@ -255,6 +255,85 @@ auq --help # Show help
255
255
 
256
256
  ---
257
257
 
258
+ ### CLI Commands
259
+
260
+ AUQ provides headless CLI commands for managing sessions and configuration without the TUI.
261
+
262
+ #### Answer Sessions
263
+
264
+ ```bash
265
+ # Answer a session with JSON answers
266
+ auq answer <sessionId> --answers '{"0": {"selectedOption": "option1"}}'
267
+
268
+ # Reject a session
269
+ auq answer <sessionId> --reject --reason "Not applicable"
270
+
271
+ # Force answer an abandoned session
272
+ auq answer <sessionId> --answers '...' --force
273
+
274
+ # JSON output
275
+ auq answer <sessionId> --answers '...' --json
276
+ ```
277
+
278
+ #### Manage Sessions
279
+
280
+ ```bash
281
+ # List pending sessions
282
+ auq sessions list
283
+
284
+ # List stale sessions only
285
+ auq sessions list --stale
286
+
287
+ # List all sessions (including completed, abandoned)
288
+ auq sessions list --all
289
+
290
+ # Dismiss/archive a session
291
+ auq sessions dismiss <sessionId>
292
+
293
+ # JSON output
294
+ auq sessions list --json
295
+ ```
296
+
297
+ #### Configuration
298
+
299
+ ```bash
300
+ # View all configuration
301
+ auq config get
302
+
303
+ # View specific setting
304
+ auq config get staleThreshold
305
+
306
+ # Set a value (local .auqrc.json)
307
+ auq config set staleThreshold 3600000
308
+
309
+ # Set globally
310
+ auq config set staleThreshold 3600000 --global
311
+ ```
312
+
313
+ ---
314
+
315
+ ### Stale Session Detection
316
+
317
+ Sessions that remain unanswered longer than the configured threshold are marked as "stale" (potentially orphaned). This helps identify sessions where the AI may have disconnected or timed out.
318
+
319
+ - **Visual indicators**: Stale sessions show a ⚠ warning icon and yellow highlighting in the TUI
320
+ - **Toast notifications**: A notification appears when a session becomes stale (configurable)
321
+ - **Grace period**: Interacting with a stale session provides a 30-minute grace period
322
+ - **Configurable threshold**: Default is 2 hours (7,200,000ms)
323
+
324
+ ---
325
+
326
+ ### Abandoned Session Handling
327
+
328
+ When an AI client disconnects, associated sessions are marked as "abandoned". These sessions:
329
+
330
+ - Remain visible in the TUI with a red indicator
331
+ - Show a confirmation dialog before answering ("AI가 disconnect되었습니다")
332
+ - Can still be answered via CLI with the `--force` flag
333
+ - Are detectable via `auq sessions list --all`
334
+
335
+ ---
336
+
258
337
  ### 🎨 Themes
259
338
 
260
339
  AUQ supports **16 built-in color themes** with automatic persistence. Press `Ctrl+T` to cycle through themes.
@@ -461,6 +540,9 @@ _Settings from local config override global config, which overrides defaults._
461
540
  | `retentionPeriod` | number | 604800000 | 0+ (milliseconds) | How long to keep completed sessions (default: 7 days) |
462
541
  | `notifications.enabled` | boolean | true | true/false | Enable desktop notifications for new questions |
463
542
  | `notifications.sound` | boolean | true | true/false | Play sound with notifications |
543
+ | `staleThreshold` | number | 7200000 | 0+ (milliseconds) | Time before a session is considered stale (2 hours) |
544
+ | `notifyOnStale` | boolean | true | true/false | Show toast notification when sessions become stale |
545
+ | `staleAction` | string | "warn" | "warn", "remove", "archive" | Action for stale sessions |
464
546
 
465
547
  </details>
466
548
 
package/dist/bin/auq.js CHANGED
@@ -9,54 +9,42 @@ import { getSessionDirectory } from "../src/session/utils.js";
9
9
  const args = process.argv.slice(2);
10
10
  const command = args[0];
11
11
  if (command === "--help" || command === "-h") {
12
- console.log(`
13
- AUQ - Ask User Questions
12
+ console.log(`auq - Ask User Questions (MCP server + TUI)
14
13
 
15
- Usage:
16
- auq [command] [options]
14
+ Usage: auq [command] [options]
17
15
 
18
16
  Commands:
19
- (default) Start the TUI (Terminal User Interface)
20
- server Start the MCP server (for use with MCP clients)
21
- ask <json> Ask questions via CLI (alternative to MCP)
17
+ (default) Start interactive TUI
18
+ server Start MCP server (stdio)
19
+ ask <json> Ask questions via CLI
20
+ answer <id> [flags] Answer or reject a session
21
+ sessions <sub> [flags] List/dismiss sessions
22
+ config <sub> [flags] Get/set configuration
22
23
 
23
- Options:
24
- --help, -h Show this help message
25
- --version, -v Show version information
26
-
27
- ASK COMMAND:
28
- Use 'auq ask' when you need to ask the user questions during execution.
29
- This allows you to:
30
- 1. Gather user preferences or requirements
31
- 2. Clarify ambiguous instructions
32
- 3. Get decisions on implementation choices as you work
33
- 4. Offer choices to the user about what direction to take
24
+ Answer:
25
+ auq answer <id> --answers '<json>' Submit answers
26
+ auq answer <id> --reject [--reason] Reject session
27
+ Flags: --force --json
34
28
 
35
- FEATURES:
36
- - Ask 1-4 structured questions via an interactive terminal interface
37
- - Each question includes 2-4 multiple-choice options with explanatory descriptions
38
- - Users can always provide custom free-text input as an alternative to predefined options
39
- - Single-select mode (default): User picks ONE option or provides custom text
40
- - Multi-select mode (multiSelect: true): User can select MULTIPLE options
29
+ Sessions:
30
+ auq sessions list [--pending|--stale|--all] [--json]
31
+ auq sessions dismiss <id> [--force] [--json]
41
32
 
42
- USAGE NOTES:
43
- - Provide a descriptive 'title' field (max 12 chars) for each question
44
- - Use multiSelect: true when choices are not mutually exclusive
45
- - Option labels should be concise (1-5 words)
46
- - To mark a recommended option, append '(recommended)' to its label
47
- - Don't include an 'Other' option - it's provided automatically
33
+ Config:
34
+ auq config get [key] [--json]
35
+ auq config set <key> <value> [--global] [--json]
48
36
 
49
- Returns a formatted summary of all questions and answers.
37
+ Options:
38
+ -h, --help Show this help
39
+ -v, --version Show version
50
40
 
51
- Examples:
52
- auq # Start TUI (wait for questions from AI)
53
- auq server # Start MCP server (for Claude Desktop, etc.)
54
- auq ask '{"questions": [{"prompt": "Which language?", "title": "Lang", "options": [{"label": "TypeScript (recommended)"}, {"label": "Python"}], "multiSelect": false}]}'
55
- echo '{"questions": [...]}' | auq ask # Pipe JSON to ask command
41
+ Keys (TUI):
42
+ ↑↓ navigate ←→/Tab questions Space toggle Enter select
43
+ R recommend Ctrl+R quick-submit Esc reject
44
+ [/] sessions 1-9 jump Ctrl+S picker Ctrl+T theme
56
45
 
57
- For more information, visit:
58
- https://github.com/paulp-o/ask-user-questions-mcp
59
- `);
46
+ Config: ./.auqrc.json (local) > ~/.config/auq/.auqrc.json (global)
47
+ Env: AUQ_SESSION_DIR XDG_CONFIG_HOME`);
60
48
  process.exit(0);
61
49
  }
62
50
  // Display version
@@ -168,6 +156,24 @@ if (command === "ask") {
168
156
  process.exit(1);
169
157
  }
170
158
  }
159
+ // Handle 'answer' command
160
+ if (command === "answer") {
161
+ const { runAnswerCommand } = await import("../src/cli/commands/answer.js");
162
+ await runAnswerCommand(args.slice(1));
163
+ process.exit(0);
164
+ }
165
+ // Handle 'sessions' command
166
+ if (command === "sessions") {
167
+ const { runSessionsCommand } = await import("../src/cli/commands/sessions.js");
168
+ await runSessionsCommand(args.slice(1));
169
+ process.exit(0);
170
+ }
171
+ // Handle 'config' command
172
+ if (command === "config") {
173
+ const { runConfigCommand } = await import("../src/cli/commands/config.js");
174
+ await runConfigCommand(args.slice(1));
175
+ process.exit(0);
176
+ }
171
177
  // Default: Start TUI
172
178
  // Important: Lazy-load Ink/React so non-interactive commands (ask/server) don't pull them in.
173
179
  // Also force production mode before importing React/Ink to avoid perf_hooks measure accumulation warnings.
@@ -11,9 +11,11 @@ import { Toast } from "../src/tui/components/Toast.js";
11
11
  import { WaitingScreen } from "../src/tui/components/WaitingScreen.js";
12
12
  import { createNotificationBatcher, showProgress, clearProgress, calculateProgress, checkLinuxDependencies, } from "../src/tui/notifications/index.js";
13
13
  import { createTUIWatcher } from "../src/tui/session-watcher.js";
14
+ import { isSessionStale, isSessionAbandoned, formatStaleToastMessage, } from "../src/tui/utils/staleDetection.js";
14
15
  import { ThemeProvider } from "../src/tui/ThemeProvider.js";
15
16
  import { ConfigProvider } from "../src/tui/ConfigContext.js";
16
17
  import { getAdjustedIndexAfterRemoval, getDirectJumpIndex, getNextSessionIndex, getPrevSessionIndex, } from "../src/tui/utils/sessionSwitching.js";
18
+ import { KEYS } from "../src/tui/constants/keybindings.js";
17
19
  const App = ({ config }) => {
18
20
  const [state, setState] = useState({ mode: "WAITING" });
19
21
  const [sessionQueue, setSessionQueue] = useState([]);
@@ -24,6 +26,9 @@ const App = ({ config }) => {
24
26
  const [showSessionLog, setShowSessionLog] = useState(true);
25
27
  const [showSessionPicker, setShowSessionPicker] = useState(false);
26
28
  const [isInReviewOrRejection, setIsInReviewOrRejection] = useState(false);
29
+ const [sessionMeta, setSessionMeta] = useState(new Map());
30
+ const [lastInteractions, setLastInteractions] = useState(new Map());
31
+ const [staleToastShown, setStaleToastShown] = useState(new Set());
27
32
  // Get session directory for logging
28
33
  const sessionDir = getSessionDirectory();
29
34
  // Notification configuration from config
@@ -53,6 +58,7 @@ const App = ({ config }) => {
53
58
  // Step 1: Load existing pending sessions
54
59
  const watcher = createTUIWatcher();
55
60
  const sessionIds = await watcher.getPendingSessions();
61
+ const sessionsWithStatus = await watcher.getPendingSessionsWithStatus();
56
62
  const sessionData = await Promise.all(sessionIds.map(async (sessionId) => {
57
63
  const sessionRequest = await watcher.getSessionRequest(sessionId);
58
64
  if (!sessionRequest)
@@ -68,6 +74,12 @@ const App = ({ config }) => {
68
74
  .filter((s) => s !== null)
69
75
  .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
70
76
  setSessionQueue(validSessions);
77
+ // Build initial sessionMeta from status data
78
+ const initialMeta = new Map();
79
+ for (const meta of sessionsWithStatus) {
80
+ initialMeta.set(meta.sessionId, { status: meta.status, createdAt: meta.createdAt });
81
+ }
82
+ setSessionMeta(initialMeta);
71
83
  setIsInitialized(true);
72
84
  // Step 2: Start persistent watcher for new sessions
73
85
  watcherInstance = createTUIWatcher({ autoLoadData: true });
@@ -152,8 +164,7 @@ const App = ({ config }) => {
152
164
  const parsed = JSON.parse(content);
153
165
  if (parsed.status === "timed_out" ||
154
166
  parsed.status === "completed" ||
155
- parsed.status === "rejected" ||
156
- parsed.status === "abandoned") {
167
+ parsed.status === "rejected") {
157
168
  return {
158
169
  notifyAsTimedOut: parsed.status === "timed_out",
159
170
  session,
@@ -214,11 +225,47 @@ const App = ({ config }) => {
214
225
  const interval = setInterval(() => {
215
226
  void checkPausedSessionStatuses();
216
227
  }, 2000);
228
+ // --- Stale detection (runs alongside status polling) ---
229
+ const staleThreshold = config?.staleThreshold ?? 7200000;
230
+ const notifyOnStale = config?.notifyOnStale ?? true;
231
+ const runStaleDetection = async () => {
232
+ // Refresh session metadata from disk
233
+ const watcher = createTUIWatcher();
234
+ let freshMeta = [];
235
+ try {
236
+ freshMeta = await watcher.getPendingSessionsWithStatus();
237
+ }
238
+ catch {
239
+ // Non-critical — stale detection simply skips this cycle
240
+ }
241
+ if (freshMeta.length > 0) {
242
+ setSessionMeta((prev) => {
243
+ const next = new Map(prev);
244
+ for (const meta of freshMeta) {
245
+ next.set(meta.sessionId, { status: meta.status, createdAt: meta.createdAt });
246
+ }
247
+ return next;
248
+ });
249
+ }
250
+ // Show toast for newly stale sessions
251
+ for (const session of sessionQueue) {
252
+ const stale = isSessionStale(session.timestamp.getTime(), staleThreshold, lastInteractions.get(session.sessionId));
253
+ if (stale && notifyOnStale && !staleToastShown.has(session.sessionId)) {
254
+ const title = session.sessionRequest.questions[0]?.title ?? session.sessionId.slice(0, 8);
255
+ showToast(formatStaleToastMessage(title, session.timestamp.getTime()), "info");
256
+ setStaleToastShown((prev) => new Set(prev).add(session.sessionId));
257
+ }
258
+ }
259
+ };
260
+ const staleInterval = setInterval(() => {
261
+ void runStaleDetection();
262
+ }, 2000);
217
263
  return () => {
218
264
  isCancelled = true;
219
265
  clearInterval(interval);
266
+ clearInterval(staleInterval);
220
267
  };
221
- }, [activeSessionIndex, sessionDir, sessionQueue, state.mode]);
268
+ }, [activeSessionIndex, sessionDir, sessionQueue, state.mode, config?.staleThreshold, config?.notifyOnStale, lastInteractions, staleToastShown]);
222
269
  // Handle progress updates from StepperView
223
270
  const handleProgressUpdate = (answered, total) => {
224
271
  const percent = calculateProgress(answered, total);
@@ -229,6 +276,8 @@ const App = ({ config }) => {
229
276
  ...prev,
230
277
  [sessionId]: ui,
231
278
  }));
279
+ // Track interaction for stale grace time
280
+ setLastInteractions((prev) => new Map(prev).set(sessionId, Date.now()));
232
281
  }, []);
233
282
  const handleFlowStateChange = useCallback((flowState) => {
234
283
  setIsInReviewOrRejection(flowState.showReview || flowState.showRejectionConfirm);
@@ -247,6 +296,13 @@ const App = ({ config }) => {
247
296
  }
248
297
  setActiveSessionIndex(clampedIndex);
249
298
  setShowSessionPicker(false);
299
+ // Track interaction for stale grace time
300
+ setLastInteractions((prev) => {
301
+ const targetSession = sessionQueue[clampedIndex];
302
+ if (!targetSession)
303
+ return prev;
304
+ return new Map(prev).set(targetSession.sessionId, Date.now());
305
+ });
250
306
  }, [activeSessionIndex, sessionQueue, state.mode]);
251
307
  const activeSession = state.mode === "PROCESSING" ? sessionQueue[activeSessionIndex] : undefined;
252
308
  const canUseDirectJump = !activeSession ||
@@ -257,12 +313,18 @@ const App = ({ config }) => {
257
313
  setShowSessionPicker(true);
258
314
  return;
259
315
  }
260
- if (key.ctrl && input === "]") {
316
+ if (!key.ctrl && !key.meta && input === KEYS.SESSION_NEXT) {
317
+ if (!canUseDirectJump) {
318
+ return;
319
+ }
261
320
  const nextIndex = getNextSessionIndex(activeSessionIndex, sessionQueue.length);
262
321
  switchToSession(nextIndex);
263
322
  return;
264
323
  }
265
- if (key.ctrl && input === "[") {
324
+ if (!key.ctrl && !key.meta && input === KEYS.SESSION_PREV) {
325
+ if (!canUseDirectJump) {
326
+ return;
327
+ }
266
328
  const prevIndex = getPrevSessionIndex(activeSessionIndex, sessionQueue.length);
267
329
  switchToSession(prevIndex);
268
330
  return;
@@ -334,7 +396,7 @@ const App = ({ config }) => {
334
396
  mainContent = React.createElement(WaitingScreen, { queueCount: sessionQueue.length });
335
397
  }
336
398
  else {
337
- mainContent = (React.createElement(StepperView, { key: session.sessionId, onComplete: handleSessionComplete, onProgress: handleProgressUpdate, initialState: sessionUIStates[session.sessionId], onStateSnapshot: handleStateSnapshot, onFlowStateChange: handleFlowStateChange, hasMultipleSessions: sessionQueue.length >= 2, sessionId: session.sessionId, sessionRequest: session.sessionRequest }));
399
+ mainContent = (React.createElement(StepperView, { key: session.sessionId, onComplete: handleSessionComplete, onProgress: handleProgressUpdate, initialState: sessionUIStates[session.sessionId], onStateSnapshot: handleStateSnapshot, onFlowStateChange: handleFlowStateChange, hasMultipleSessions: sessionQueue.length >= 2, sessionId: session.sessionId, sessionRequest: session.sessionRequest, isAbandoned: isSessionAbandoned(sessionMeta.get(session.sessionId)?.status ?? "") }));
338
400
  }
339
401
  }
340
402
  // Render with header, toast overlay, and main content
@@ -347,14 +409,22 @@ const App = ({ config }) => {
347
409
  ? Math.max(0, sessionQueue.length - 1)
348
410
  : sessionQueue.length }),
349
411
  mainContent,
350
- state.mode === "PROCESSING" && sessionQueue.length >= 2 && (React.createElement(SessionDots, { sessions: sessionQueue, activeIndex: activeSessionIndex, sessionUIStates: sessionUIStates })),
412
+ state.mode === "PROCESSING" && sessionQueue.length >= 2 && (React.createElement(SessionDots, { sessions: sessionQueue.map((s) => ({
413
+ ...s,
414
+ isStale: isSessionStale(s.timestamp.getTime(), config?.staleThreshold ?? 7200000, lastInteractions.get(s.sessionId)),
415
+ isAbandoned: isSessionAbandoned(sessionMeta.get(s.sessionId)?.status ?? ""),
416
+ })), activeIndex: activeSessionIndex, sessionUIStates: sessionUIStates })),
351
417
  toast && (React.createElement(Box, { marginTop: 1, justifyContent: "center" },
352
418
  React.createElement(Toast, { message: toast.message, onDismiss: () => setToast(null), type: toast.type, title: toast.title, duration: 5000 }))),
353
419
  showSessionLog && (React.createElement(Box, { marginTop: 1 },
354
420
  React.createElement(Text, { dimColor: true },
355
421
  "[AUQ] Session directory: ",
356
422
  sessionDir))),
357
- state.mode === "PROCESSING" && (React.createElement(SessionPicker, { isOpen: showSessionPicker, sessions: sessionQueue, activeIndex: activeSessionIndex, sessionUIStates: sessionUIStates, onSelectIndex: (idx) => {
423
+ state.mode === "PROCESSING" && (React.createElement(SessionPicker, { isOpen: showSessionPicker, sessions: sessionQueue.map((s) => ({
424
+ ...s,
425
+ isStale: isSessionStale(s.timestamp.getTime(), config?.staleThreshold ?? 7200000, lastInteractions.get(s.sessionId)),
426
+ isAbandoned: isSessionAbandoned(sessionMeta.get(s.sessionId)?.status ?? ""),
427
+ })), activeIndex: activeSessionIndex, sessionUIStates: sessionUIStates, onSelectIndex: (idx) => {
358
428
  switchToSession(idx);
359
429
  setShowSessionPicker(false);
360
430
  }, onClose: () => setShowSessionPicker(false) })),
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auq-mcp-server",
3
- "version": "2.2.2",
3
+ "version": "2.4.0",
4
4
  "main": "dist/index.js",
5
5
  "bin": {
6
6
  "auq": "dist/bin/auq.js"
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Tests for AbortSignal and disconnect handling in SessionManager
3
+ */
4
+ import { promises as fs } from "fs";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { SessionManager } from "../session/index.js";
7
+ const testQuestions = [
8
+ {
9
+ options: [
10
+ { description: "Dynamic language", label: "JavaScript" },
11
+ { description: "Static typing", label: "TypeScript" },
12
+ ],
13
+ prompt: "Which programming language do you prefer?",
14
+ title: "Language",
15
+ },
16
+ ];
17
+ describe("AbortSignal and Disconnect Handling", () => {
18
+ let sessionManager;
19
+ const testBaseDir = "/tmp/auq-test-abort";
20
+ beforeEach(async () => {
21
+ await fs.rm(testBaseDir, { force: true, recursive: true }).catch(() => { });
22
+ sessionManager = new SessionManager({
23
+ baseDir: testBaseDir,
24
+ maxSessions: 10,
25
+ sessionTimeout: 5000,
26
+ });
27
+ await sessionManager.initialize();
28
+ });
29
+ afterEach(async () => {
30
+ await fs.rm(testBaseDir, { force: true, recursive: true }).catch(() => { });
31
+ });
32
+ describe("waitForAnswers with AbortSignal", () => {
33
+ it("should throw ABORTED when signal fires during polling", async () => {
34
+ const sessionId = await sessionManager.createSession(testQuestions);
35
+ const controller = new AbortController();
36
+ // Abort after a short delay
37
+ setTimeout(() => controller.abort(), 100);
38
+ await expect(sessionManager.waitForAnswers(sessionId, 0, undefined, controller.signal)).rejects.toThrow("ABORTED");
39
+ });
40
+ it("should throw ABORTED immediately when signal is already aborted", async () => {
41
+ const sessionId = await sessionManager.createSession(testQuestions);
42
+ const controller = new AbortController();
43
+ controller.abort(); // Pre-abort
44
+ await expect(sessionManager.waitForAnswers(sessionId, 0, undefined, controller.signal)).rejects.toThrow("ABORTED");
45
+ });
46
+ it("should work normally when signal is never aborted", async () => {
47
+ const sessionId = await sessionManager.createSession(testQuestions);
48
+ const controller = new AbortController();
49
+ // Write answers after a short delay
50
+ setTimeout(async () => {
51
+ await sessionManager.saveSessionAnswers(sessionId, {
52
+ answers: [{ questionIndex: 0, selectedOption: "JavaScript", timestamp: new Date().toISOString() }],
53
+ timestamp: new Date().toISOString(),
54
+ sessionId,
55
+ });
56
+ }, 100);
57
+ const result = await sessionManager.waitForAnswers(sessionId, 5000, undefined, controller.signal);
58
+ expect(result).toBe(sessionId);
59
+ });
60
+ });
61
+ describe("startSession with AbortSignal", () => {
62
+ it("should throw ABORTED when pre-aborted signal is passed", async () => {
63
+ const controller = new AbortController();
64
+ controller.abort(); // Pre-abort
65
+ await expect(sessionManager.startSession(testQuestions, "test-call", undefined, controller.signal)).rejects.toThrow("ABORTED");
66
+ });
67
+ it("should mark session as abandoned when pre-aborted signal is passed", async () => {
68
+ const controller = new AbortController();
69
+ controller.abort(); // Pre-abort
70
+ try {
71
+ await sessionManager.startSession(testQuestions, "test-call", undefined, controller.signal);
72
+ }
73
+ catch {
74
+ // Expected error
75
+ }
76
+ // Get session IDs and check the last one's status
77
+ const sessionIds = await sessionManager.getAllSessionIds();
78
+ expect(sessionIds.length).toBeGreaterThan(0);
79
+ const lastSessionId = sessionIds[sessionIds.length - 1];
80
+ const status = await sessionManager.getSessionStatus(lastSessionId);
81
+ expect(status?.status).toBe("abandoned");
82
+ });
83
+ it("should mark session as abandoned when signal aborts during wait", async () => {
84
+ const controller = new AbortController();
85
+ // Abort after session is created but before answers arrive
86
+ setTimeout(() => controller.abort(), 200);
87
+ await expect(sessionManager.startSession(testQuestions, "test-call", undefined, controller.signal)).rejects.toThrow("ABORTED");
88
+ // Verify session was marked abandoned
89
+ const sessionIds = await sessionManager.getAllSessionIds();
90
+ expect(sessionIds.length).toBeGreaterThan(0);
91
+ const lastSessionId = sessionIds[sessionIds.length - 1];
92
+ // Give abort handler time to update status
93
+ await new Promise((resolve) => setTimeout(resolve, 100));
94
+ const status = await sessionManager.getSessionStatus(lastSessionId);
95
+ expect(status?.status).toBe("abandoned");
96
+ });
97
+ it("should clean up abort handler after successful completion", async () => {
98
+ const controller = new AbortController();
99
+ const signal = controller.signal;
100
+ // Create a session and immediately provide answers
101
+ const sessionPromise = sessionManager.startSession(testQuestions, "test-call", undefined, signal);
102
+ // Write answers quickly
103
+ // First we need to get the session ID, but startSession creates it internally
104
+ // We'll poll for any pending session
105
+ await new Promise((resolve) => setTimeout(resolve, 100));
106
+ const sessionIds = await sessionManager.getAllSessionIds();
107
+ if (sessionIds.length > 0) {
108
+ const lastSessionId = sessionIds[sessionIds.length - 1];
109
+ await sessionManager.saveSessionAnswers(lastSessionId, {
110
+ answers: [{ questionIndex: 0, selectedOption: "JavaScript", timestamp: new Date().toISOString() }],
111
+ timestamp: new Date().toISOString(),
112
+ sessionId: lastSessionId,
113
+ });
114
+ }
115
+ const result = await sessionPromise;
116
+ expect(result.formattedResponse).toBeDefined();
117
+ expect(result.sessionId).toBeDefined();
118
+ // After successful completion, aborting should have no effect
119
+ // (handler was cleaned up)
120
+ controller.abort();
121
+ // Session should still be completed, not abandoned
122
+ const status = await sessionManager.getSessionStatus(result.sessionId);
123
+ expect(status?.status).toBe("completed");
124
+ });
125
+ });
126
+ describe("createAskUserQuestionsCore with abort support", () => {
127
+ it("should expose markAbandoned method", async () => {
128
+ const { createAskUserQuestionsCore } = await import("../core/ask-user-questions.js");
129
+ const core = createAskUserQuestionsCore({
130
+ baseDir: testBaseDir,
131
+ sessionManager,
132
+ });
133
+ expect(core.markAbandoned).toBeDefined();
134
+ expect(typeof core.markAbandoned).toBe("function");
135
+ });
136
+ it("should mark session as abandoned via markAbandoned", async () => {
137
+ const { createAskUserQuestionsCore } = await import("../core/ask-user-questions.js");
138
+ const core = createAskUserQuestionsCore({
139
+ baseDir: testBaseDir,
140
+ sessionManager,
141
+ });
142
+ const sessionId = await sessionManager.createSession(testQuestions);
143
+ await core.markAbandoned(sessionId);
144
+ const status = await sessionManager.getSessionStatus(sessionId);
145
+ expect(status?.status).toBe("abandoned");
146
+ });
147
+ it("should pass signal through ask to startSession", async () => {
148
+ const { createAskUserQuestionsCore } = await import("../core/ask-user-questions.js");
149
+ const core = createAskUserQuestionsCore({
150
+ baseDir: testBaseDir,
151
+ sessionManager,
152
+ });
153
+ await core.ensureInitialized();
154
+ const controller = new AbortController();
155
+ controller.abort(); // Pre-abort
156
+ await expect(core.ask([
157
+ {
158
+ options: [
159
+ { description: "Dynamic language", label: "JavaScript" },
160
+ { description: "Static typing", label: "TypeScript" },
161
+ ],
162
+ prompt: "Which programming language do you prefer?",
163
+ title: "Language",
164
+ multiSelect: false,
165
+ },
166
+ ], "test-call", undefined, controller.signal)).rejects.toThrow("ABORTED");
167
+ });
168
+ });
169
+ describe("activeRequests tracking (server integration)", () => {
170
+ it("should track and clean up active requests via Map", () => {
171
+ // Unit test for the activeRequests Map pattern used in server.ts
172
+ const activeRequests = new Map();
173
+ const controller = new AbortController();
174
+ const callId = "test-call-id";
175
+ // Track request
176
+ activeRequests.set(callId, { controller });
177
+ expect(activeRequests.size).toBe(1);
178
+ // Update with sessionId
179
+ const entry = activeRequests.get(callId);
180
+ expect(entry).toBeDefined();
181
+ entry.sessionId = "test-session-id";
182
+ // Verify update
183
+ expect(activeRequests.get(callId)?.sessionId).toBe("test-session-id");
184
+ // Simulate disconnect - abort and clean up
185
+ for (const [id, e] of activeRequests.entries()) {
186
+ e.controller.abort();
187
+ activeRequests.delete(id);
188
+ }
189
+ expect(activeRequests.size).toBe(0);
190
+ expect(controller.signal.aborted).toBe(true);
191
+ });
192
+ it("should handle multiple concurrent requests on disconnect", () => {
193
+ const activeRequests = new Map();
194
+ // Track multiple requests
195
+ const controllers = [];
196
+ for (let i = 0; i < 3; i++) {
197
+ const controller = new AbortController();
198
+ controllers.push(controller);
199
+ activeRequests.set(`call-${i}`, {
200
+ controller,
201
+ sessionId: `session-${i}`,
202
+ });
203
+ }
204
+ expect(activeRequests.size).toBe(3);
205
+ // Simulate disconnect
206
+ for (const [callId, entry] of activeRequests.entries()) {
207
+ entry.controller.abort();
208
+ activeRequests.delete(callId);
209
+ }
210
+ expect(activeRequests.size).toBe(0);
211
+ controllers.forEach((c) => expect(c.signal.aborted).toBe(true));
212
+ });
213
+ });
214
+ });