auq-mcp-server 2.3.0 → 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.
- package/README.md +82 -0
- package/dist/bin/auq.js +47 -93
- package/dist/bin/tui-app.js +69 -6
- package/dist/package.json +1 -1
- package/dist/src/__tests__/server.abort.test.js +214 -0
- package/dist/src/cli/commands/__tests__/answer.test.js +199 -0
- package/dist/src/cli/commands/__tests__/config.test.js +218 -0
- package/dist/src/cli/commands/__tests__/sessions.test.js +282 -0
- package/dist/src/cli/commands/answer.js +128 -0
- package/dist/src/cli/commands/config.js +263 -0
- package/dist/src/cli/commands/sessions.js +164 -0
- package/dist/src/cli/utils.js +95 -0
- package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
- package/dist/src/config/defaults.js +3 -0
- package/dist/src/config/types.js +4 -0
- package/dist/src/core/ask-user-questions.js +3 -2
- package/dist/src/i18n/locales/en.js +7 -0
- package/dist/src/i18n/locales/ko.js +7 -0
- package/dist/src/server.js +64 -11
- package/dist/src/session/SessionManager.js +69 -4
- package/dist/src/session/__tests__/SessionManager.test.js +65 -0
- package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
- package/dist/src/tui/components/SessionDots.js +33 -4
- package/dist/src/tui/components/SessionPicker.js +25 -17
- package/dist/src/tui/components/Spinner.js +19 -0
- package/dist/src/tui/components/StepperView.js +68 -5
- package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
- package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
- package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
- package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
- package/dist/src/tui/session-watcher.js +50 -0
- package/dist/src/tui/themes/catppuccin-latte.js +7 -0
- package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
- package/dist/src/tui/themes/dark.js +7 -0
- package/dist/src/tui/themes/dracula.js +7 -0
- package/dist/src/tui/themes/github-dark.js +7 -0
- package/dist/src/tui/themes/github-light.js +7 -0
- package/dist/src/tui/themes/gruvbox-dark.js +7 -0
- package/dist/src/tui/themes/gruvbox-light.js +7 -0
- package/dist/src/tui/themes/light.js +7 -0
- package/dist/src/tui/themes/monokai.js +7 -0
- package/dist/src/tui/themes/nord.js +7 -0
- package/dist/src/tui/themes/one-dark.js +7 -0
- package/dist/src/tui/themes/rose-pine.js +7 -0
- package/dist/src/tui/themes/solarized-dark.js +7 -0
- package/dist/src/tui/themes/solarized-light.js +7 -0
- package/dist/src/tui/themes/tokyo-night.js +7 -0
- package/dist/src/tui/utils/__tests__/detectTheme.test.js +78 -0
- package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
- package/dist/src/tui/utils/staleDetection.js +51 -0
- 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,106 +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
|
-
|
|
16
|
-
|
|
17
|
-
Usage:
|
|
18
|
-
auq [command] [options]
|
|
14
|
+
Usage: auq [command] [options]
|
|
19
15
|
|
|
20
16
|
Commands:
|
|
21
|
-
(default)
|
|
22
|
-
server
|
|
23
|
-
ask <json>
|
|
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
|
|
23
|
+
|
|
24
|
+
Answer:
|
|
25
|
+
auq answer <id> --answers '<json>' Submit answers
|
|
26
|
+
auq answer <id> --reject [--reason] Reject session
|
|
27
|
+
Flags: --force --json
|
|
28
|
+
|
|
29
|
+
Sessions:
|
|
30
|
+
auq sessions list [--pending|--stale|--all] [--json]
|
|
31
|
+
auq sessions dismiss <id> [--force] [--json]
|
|
32
|
+
|
|
33
|
+
Config:
|
|
34
|
+
auq config get [key] [--json]
|
|
35
|
+
auq config set <key> <value> [--global] [--json]
|
|
24
36
|
|
|
25
37
|
Options:
|
|
26
|
-
--help
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
TUI Keyboard Shortcuts:
|
|
30
|
-
Navigation:
|
|
31
|
-
↑/↓ Navigate options
|
|
32
|
-
←/→ Navigate questions
|
|
33
|
-
Tab/Shift+Tab Navigate questions
|
|
34
|
-
|
|
35
|
-
Selection:
|
|
36
|
-
Space Select/toggle option (multi-select)
|
|
37
|
-
Enter Select option & advance to next question
|
|
38
|
-
R Select recommended option(s)
|
|
39
|
-
Ctrl+R Quick submit (auto-select all recommended)
|
|
40
|
-
|
|
41
|
-
Session Management:
|
|
42
|
-
] Next session
|
|
43
|
-
[ Previous session
|
|
44
|
-
1-9 Jump to session by number
|
|
45
|
-
Ctrl+S Open session picker
|
|
46
|
-
|
|
47
|
-
Other:
|
|
48
|
-
E Request elaboration on current question
|
|
49
|
-
Ctrl+T Cycle color theme
|
|
50
|
-
Esc Reject question set
|
|
51
|
-
|
|
52
|
-
Ask Command:
|
|
53
|
-
Use 'auq ask' when you need to ask the user questions during
|
|
54
|
-
execution. This allows you to:
|
|
55
|
-
1. Gather user preferences or requirements
|
|
56
|
-
2. Clarify ambiguous instructions
|
|
57
|
-
3. Get decisions on implementation choices as you work
|
|
58
|
-
4. Offer choices to the user about what direction to take
|
|
59
|
-
|
|
60
|
-
Features:
|
|
61
|
-
- Ask 1-5 structured questions via an interactive terminal UI
|
|
62
|
-
- Each question includes 2-5 multiple-choice options
|
|
63
|
-
- Users can always provide custom free-text input
|
|
64
|
-
- Single-select mode (default): pick ONE option or custom text
|
|
65
|
-
- Multi-select mode (multiSelect: true): select MULTIPLE options
|
|
66
|
-
|
|
67
|
-
Usage Notes:
|
|
68
|
-
- Provide a descriptive 'title' field (max 12 chars) per question
|
|
69
|
-
- Use multiSelect: true when choices are not mutually exclusive
|
|
70
|
-
- Option labels should be concise (1-5 words)
|
|
71
|
-
- To mark recommended, append '(recommended)' to option label
|
|
72
|
-
- Don't include an 'Other' option — it's provided automatically
|
|
73
|
-
|
|
74
|
-
Returns a formatted summary of all questions and answers.
|
|
75
|
-
|
|
76
|
-
Configuration:
|
|
77
|
-
Config file locations (searched in order, merged):
|
|
78
|
-
./.auqrc.json Project-level (highest priority)
|
|
79
|
-
~/.config/auq/.auqrc.json User-level (global)
|
|
38
|
+
-h, --help Show this help
|
|
39
|
+
-v, --version Show version
|
|
80
40
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
recommendedQuestions Recommended question count hint (default: 4)
|
|
86
|
-
sessionTimeout Session timeout in ms (0 = infinite, default: 0)
|
|
87
|
-
retentionPeriod Session retention in ms (default: 604800000 / 7d)
|
|
88
|
-
language UI language ("auto" | "en" | "ko", default: "auto")
|
|
89
|
-
theme Color theme ("system" | "dark" | "light" | custom,
|
|
90
|
-
default: "system")
|
|
91
|
-
autoSelectRecommended Pre-select recommended options (default: true)
|
|
92
|
-
notifications.enabled Enable desktop notifications (default: true)
|
|
93
|
-
notifications.sound Enable notification sounds (default: true)
|
|
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
|
|
94
45
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
Environment Variables:
|
|
98
|
-
AUQ_SESSION_DIR Override session storage directory
|
|
99
|
-
XDG_CONFIG_HOME Override config base directory (default: ~/.config)
|
|
100
|
-
|
|
101
|
-
Examples:
|
|
102
|
-
auq # Start TUI (wait for questions from AI)
|
|
103
|
-
auq server # Start MCP server (for Claude Desktop, etc.)
|
|
104
|
-
auq ask '{"questions": [{"prompt": "Which language?", "title": "Lang",
|
|
105
|
-
"options": [{"label": "TypeScript (recommended)"}, {"label": "Python"}],
|
|
106
|
-
"multiSelect": false}]}'
|
|
107
|
-
echo '{"questions": [...]}' | auq ask # Pipe JSON to ask command
|
|
108
|
-
|
|
109
|
-
For more information, visit:
|
|
110
|
-
https://github.com/paulp-o/ask-user-questions-mcp
|
|
111
|
-
`);
|
|
46
|
+
Config: ./.auqrc.json (local) > ~/.config/auq/.auqrc.json (global)
|
|
47
|
+
Env: AUQ_SESSION_DIR XDG_CONFIG_HOME`);
|
|
112
48
|
process.exit(0);
|
|
113
49
|
}
|
|
114
50
|
// Display version
|
|
@@ -220,6 +156,24 @@ if (command === "ask") {
|
|
|
220
156
|
process.exit(1);
|
|
221
157
|
}
|
|
222
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
|
+
}
|
|
223
177
|
// Default: Start TUI
|
|
224
178
|
// Important: Lazy-load Ink/React so non-interactive commands (ask/server) don't pull them in.
|
|
225
179
|
// Also force production mode before importing React/Ink to avoid perf_hooks measure accumulation warnings.
|
package/dist/bin/tui-app.js
CHANGED
|
@@ -11,6 +11,7 @@ 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";
|
|
@@ -25,6 +26,9 @@ const App = ({ config }) => {
|
|
|
25
26
|
const [showSessionLog, setShowSessionLog] = useState(true);
|
|
26
27
|
const [showSessionPicker, setShowSessionPicker] = useState(false);
|
|
27
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());
|
|
28
32
|
// Get session directory for logging
|
|
29
33
|
const sessionDir = getSessionDirectory();
|
|
30
34
|
// Notification configuration from config
|
|
@@ -54,6 +58,7 @@ const App = ({ config }) => {
|
|
|
54
58
|
// Step 1: Load existing pending sessions
|
|
55
59
|
const watcher = createTUIWatcher();
|
|
56
60
|
const sessionIds = await watcher.getPendingSessions();
|
|
61
|
+
const sessionsWithStatus = await watcher.getPendingSessionsWithStatus();
|
|
57
62
|
const sessionData = await Promise.all(sessionIds.map(async (sessionId) => {
|
|
58
63
|
const sessionRequest = await watcher.getSessionRequest(sessionId);
|
|
59
64
|
if (!sessionRequest)
|
|
@@ -69,6 +74,12 @@ const App = ({ config }) => {
|
|
|
69
74
|
.filter((s) => s !== null)
|
|
70
75
|
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
71
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);
|
|
72
83
|
setIsInitialized(true);
|
|
73
84
|
// Step 2: Start persistent watcher for new sessions
|
|
74
85
|
watcherInstance = createTUIWatcher({ autoLoadData: true });
|
|
@@ -153,8 +164,7 @@ const App = ({ config }) => {
|
|
|
153
164
|
const parsed = JSON.parse(content);
|
|
154
165
|
if (parsed.status === "timed_out" ||
|
|
155
166
|
parsed.status === "completed" ||
|
|
156
|
-
parsed.status === "rejected"
|
|
157
|
-
parsed.status === "abandoned") {
|
|
167
|
+
parsed.status === "rejected") {
|
|
158
168
|
return {
|
|
159
169
|
notifyAsTimedOut: parsed.status === "timed_out",
|
|
160
170
|
session,
|
|
@@ -215,11 +225,47 @@ const App = ({ config }) => {
|
|
|
215
225
|
const interval = setInterval(() => {
|
|
216
226
|
void checkPausedSessionStatuses();
|
|
217
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);
|
|
218
263
|
return () => {
|
|
219
264
|
isCancelled = true;
|
|
220
265
|
clearInterval(interval);
|
|
266
|
+
clearInterval(staleInterval);
|
|
221
267
|
};
|
|
222
|
-
}, [activeSessionIndex, sessionDir, sessionQueue, state.mode]);
|
|
268
|
+
}, [activeSessionIndex, sessionDir, sessionQueue, state.mode, config?.staleThreshold, config?.notifyOnStale, lastInteractions, staleToastShown]);
|
|
223
269
|
// Handle progress updates from StepperView
|
|
224
270
|
const handleProgressUpdate = (answered, total) => {
|
|
225
271
|
const percent = calculateProgress(answered, total);
|
|
@@ -230,6 +276,8 @@ const App = ({ config }) => {
|
|
|
230
276
|
...prev,
|
|
231
277
|
[sessionId]: ui,
|
|
232
278
|
}));
|
|
279
|
+
// Track interaction for stale grace time
|
|
280
|
+
setLastInteractions((prev) => new Map(prev).set(sessionId, Date.now()));
|
|
233
281
|
}, []);
|
|
234
282
|
const handleFlowStateChange = useCallback((flowState) => {
|
|
235
283
|
setIsInReviewOrRejection(flowState.showReview || flowState.showRejectionConfirm);
|
|
@@ -248,6 +296,13 @@ const App = ({ config }) => {
|
|
|
248
296
|
}
|
|
249
297
|
setActiveSessionIndex(clampedIndex);
|
|
250
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
|
+
});
|
|
251
306
|
}, [activeSessionIndex, sessionQueue, state.mode]);
|
|
252
307
|
const activeSession = state.mode === "PROCESSING" ? sessionQueue[activeSessionIndex] : undefined;
|
|
253
308
|
const canUseDirectJump = !activeSession ||
|
|
@@ -341,7 +396,7 @@ const App = ({ config }) => {
|
|
|
341
396
|
mainContent = React.createElement(WaitingScreen, { queueCount: sessionQueue.length });
|
|
342
397
|
}
|
|
343
398
|
else {
|
|
344
|
-
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 ?? "") }));
|
|
345
400
|
}
|
|
346
401
|
}
|
|
347
402
|
// Render with header, toast overlay, and main content
|
|
@@ -354,14 +409,22 @@ const App = ({ config }) => {
|
|
|
354
409
|
? Math.max(0, sessionQueue.length - 1)
|
|
355
410
|
: sessionQueue.length }),
|
|
356
411
|
mainContent,
|
|
357
|
-
state.mode === "PROCESSING" && sessionQueue.length >= 2 && (React.createElement(SessionDots, { sessions: sessionQueue
|
|
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 })),
|
|
358
417
|
toast && (React.createElement(Box, { marginTop: 1, justifyContent: "center" },
|
|
359
418
|
React.createElement(Toast, { message: toast.message, onDismiss: () => setToast(null), type: toast.type, title: toast.title, duration: 5000 }))),
|
|
360
419
|
showSessionLog && (React.createElement(Box, { marginTop: 1 },
|
|
361
420
|
React.createElement(Text, { dimColor: true },
|
|
362
421
|
"[AUQ] Session directory: ",
|
|
363
422
|
sessionDir))),
|
|
364
|
-
state.mode === "PROCESSING" && (React.createElement(SessionPicker, { isOpen: showSessionPicker, sessions: sessionQueue
|
|
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) => {
|
|
365
428
|
switchToSession(idx);
|
|
366
429
|
setShowSessionPicker(false);
|
|
367
430
|
}, onClose: () => setShowSessionPicker(false) })),
|
package/dist/package.json
CHANGED
|
@@ -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
|
+
});
|