@moxxy/sdk 0.43.0 → 0.45.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/dist/agent-account-limit.d.ts +41 -0
- package/dist/agent-account-limit.d.ts.map +1 -0
- package/dist/agent-account-limit.js +64 -0
- package/dist/agent-account-limit.js.map +1 -0
- package/dist/agent-computer-use.d.ts +10 -0
- package/dist/agent-computer-use.d.ts.map +1 -0
- package/dist/agent-computer-use.js +24 -0
- package/dist/agent-computer-use.js.map +1 -0
- package/dist/agent-context-window.d.ts +22 -0
- package/dist/agent-context-window.d.ts.map +1 -0
- package/dist/agent-context-window.js +40 -0
- package/dist/agent-context-window.js.map +1 -0
- package/dist/agent-no-reply.d.ts +11 -0
- package/dist/agent-no-reply.d.ts.map +1 -0
- package/dist/agent-no-reply.js +13 -0
- package/dist/agent-no-reply.js.map +1 -0
- package/dist/agent-step.d.ts +14 -0
- package/dist/agent-step.d.ts.map +1 -0
- package/dist/agent-step.js +26 -0
- package/dist/agent-step.js.map +1 -0
- package/dist/agent-work.d.ts +34 -0
- package/dist/agent-work.d.ts.map +1 -0
- package/dist/agent-work.js +80 -0
- package/dist/agent-work.js.map +1 -0
- package/dist/attachment-payload.d.ts +95 -0
- package/dist/attachment-payload.d.ts.map +1 -0
- package/dist/attachment-payload.js +155 -0
- package/dist/attachment-payload.js.map +1 -0
- package/dist/auto-approve.d.ts +9 -0
- package/dist/auto-approve.d.ts.map +1 -1
- package/dist/auto-approve.js +18 -0
- package/dist/auto-approve.js.map +1 -1
- package/dist/channel-auth.d.ts +3 -3
- package/dist/channel-auth.d.ts.map +1 -1
- package/dist/channel-auth.js +1 -1
- package/dist/channel.d.ts +1 -1
- package/dist/client-session.d.ts +9 -5
- package/dist/client-session.d.ts.map +1 -1
- package/dist/computer-control.d.ts +11 -11
- package/dist/computer-control.d.ts.map +1 -1
- package/dist/computer-control.js +1 -1
- package/dist/computer-control.js.map +1 -1
- package/dist/conversation-history.d.ts +50 -0
- package/dist/conversation-history.d.ts.map +1 -0
- package/dist/conversation-history.js +227 -0
- package/dist/conversation-history.js.map +1 -0
- package/dist/cron.d.ts +58 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +320 -0
- package/dist/cron.js.map +1 -0
- package/dist/define.d.ts +2 -1
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +1 -0
- package/dist/define.js.map +1 -1
- package/dist/event-store.d.ts +1 -1
- package/dist/event-store.d.ts.map +1 -1
- package/dist/event-store.js +1 -0
- package/dist/event-store.js.map +1 -1
- package/dist/events.d.ts +23 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/file-diff.d.ts +31 -0
- package/dist/file-diff.d.ts.map +1 -0
- package/dist/file-diff.js +196 -0
- package/dist/file-diff.js.map +1 -0
- package/dist/index.d.ts +23 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/dist/mode/agent-step-loop.d.ts +13 -0
- package/dist/mode/agent-step-loop.d.ts.map +1 -0
- package/dist/mode/agent-step-loop.js +578 -0
- package/dist/mode/agent-step-loop.js.map +1 -0
- package/dist/mode/checkpoint-gate.d.ts +25 -0
- package/dist/mode/checkpoint-gate.d.ts.map +1 -0
- package/dist/mode/checkpoint-gate.js +150 -0
- package/dist/mode/checkpoint-gate.js.map +1 -0
- package/dist/mode/checkpoint.d.ts +6 -0
- package/dist/mode/checkpoint.d.ts.map +1 -1
- package/dist/mode/collect-stream.d.ts +2 -0
- package/dist/mode/collect-stream.d.ts.map +1 -1
- package/dist/mode/collect-stream.js +1 -1
- package/dist/mode/collect-stream.js.map +1 -1
- package/dist/mode/project-messages.d.ts +3 -1
- package/dist/mode/project-messages.d.ts.map +1 -1
- package/dist/mode/project-messages.js +122 -81
- package/dist/mode/project-messages.js.map +1 -1
- package/dist/mode/react-loop-options.d.ts +113 -0
- package/dist/mode/react-loop-options.d.ts.map +1 -0
- package/dist/mode/react-loop-options.js +2 -0
- package/dist/mode/react-loop-options.js.map +1 -0
- package/dist/mode/react-loop.d.ts +2 -99
- package/dist/mode/react-loop.d.ts.map +1 -1
- package/dist/mode/react-loop.js +7 -145
- package/dist/mode/react-loop.js.map +1 -1
- package/dist/mode.d.ts +19 -7
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/npm-registry.d.ts +22 -0
- package/dist/npm-registry.d.ts.map +1 -0
- package/dist/npm-registry.js +54 -0
- package/dist/npm-registry.js.map +1 -0
- package/dist/permission.d.ts +31 -0
- package/dist/permission.d.ts.map +1 -1
- package/dist/process-invocation.d.ts +2 -0
- package/dist/process-invocation.d.ts.map +1 -1
- package/dist/process-invocation.js +2 -2
- package/dist/process-invocation.js.map +1 -1
- package/dist/process-tree.d.ts +9 -0
- package/dist/process-tree.d.ts.map +1 -0
- package/dist/process-tree.js +40 -0
- package/dist/process-tree.js.map +1 -0
- package/dist/provider-login-bridge.js +1 -1
- package/dist/provider-login-bridge.js.map +1 -1
- package/dist/provider.d.ts +408 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/question-draft.d.ts +19 -0
- package/dist/question-draft.d.ts.map +1 -0
- package/dist/question-draft.js +30 -0
- package/dist/question-draft.js.map +1 -0
- package/dist/question.d.ts +204 -0
- package/dist/question.d.ts.map +1 -0
- package/dist/question.js +141 -0
- package/dist/question.js.map +1 -0
- package/dist/questions.d.ts +7 -0
- package/dist/questions.d.ts.map +1 -0
- package/dist/questions.js +7 -0
- package/dist/questions.js.map +1 -0
- package/dist/resolvers.d.ts +14 -1
- package/dist/resolvers.d.ts.map +1 -1
- package/dist/resolvers.js +33 -21
- package/dist/resolvers.js.map +1 -1
- package/dist/schemas.d.ts +53 -53
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +3 -2
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -0
- package/dist/server.js.map +1 -1
- package/dist/session-like.d.ts +19 -1
- package/dist/session-like.d.ts.map +1 -1
- package/dist/starting-model.d.ts +12 -0
- package/dist/starting-model.d.ts.map +1 -0
- package/dist/starting-model.js +9 -0
- package/dist/starting-model.js.map +1 -0
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +10 -4
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/tool-display.d.ts +79 -3
- package/dist/tool-display.d.ts.map +1 -1
- package/dist/tool-display.js +117 -3
- package/dist/tool-display.js.map +1 -1
- package/dist/tool-modes.d.ts +1 -1
- package/dist/tool-modes.d.ts.map +1 -1
- package/dist/tool-modes.js +8 -2
- package/dist/tool-modes.js.map +1 -1
- package/dist/tool-output.d.ts +15 -0
- package/dist/tool-output.d.ts.map +1 -0
- package/dist/tool-output.js +39 -0
- package/dist/tool-output.js.map +1 -0
- package/dist/tool.d.ts +14 -1
- package/dist/tool.d.ts.map +1 -1
- package/dist/tunnel.d.ts +3 -3
- package/dist/tunnel.d.ts.map +1 -1
- package/dist/tunnel.js +2 -2
- package/dist/workflow-approval.d.ts +9 -9
- package/dist/workflow-approval.d.ts.map +1 -1
- package/dist/workflow-approval.js +1 -1
- package/dist/workflow-approval.js.map +1 -1
- package/package.json +37 -5
- package/src/agent-account-limit.test.ts +87 -0
- package/src/agent-account-limit.ts +89 -0
- package/src/agent-computer-use.test.ts +26 -0
- package/src/agent-computer-use.ts +29 -0
- package/src/agent-context-window.ts +51 -0
- package/src/agent-no-reply.ts +16 -0
- package/src/agent-step.ts +26 -0
- package/src/agent-work.ts +102 -0
- package/src/attachment-payload.test.ts +165 -0
- package/src/attachment-payload.ts +187 -0
- package/src/auto-approve.test.ts +13 -1
- package/src/auto-approve.ts +19 -0
- package/src/channel-auth.ts +3 -3
- package/src/channel.ts +1 -1
- package/src/client-session.ts +9 -2
- package/src/computer-control.ts +1 -1
- package/src/conversation-history.test.ts +184 -0
- package/src/conversation-history.ts +272 -0
- package/src/cron.test.ts +280 -0
- package/src/cron.ts +355 -0
- package/src/define.test.ts +1 -1
- package/src/define.ts +3 -1
- package/src/event-store.ts +2 -1
- package/src/events.ts +23 -1
- package/src/file-diff.test.ts +103 -0
- package/src/file-diff.ts +208 -0
- package/src/index.ts +140 -1
- package/src/json-file-store.test.ts +1 -1
- package/src/loop-helpers.test.ts +15 -0
- package/src/mode/agent-step-loop.ts +645 -0
- package/src/mode/checkpoint-gate.ts +198 -0
- package/src/mode/checkpoint.ts +6 -0
- package/src/mode/collect-stream.ts +1 -1
- package/src/mode/project-messages.test.ts +118 -1
- package/src/mode/project-messages.ts +136 -77
- package/src/mode/react-loop-options.ts +124 -0
- package/src/mode/react-loop.ts +10 -296
- package/src/mode.ts +19 -7
- package/src/npm-registry.test.ts +95 -0
- package/src/npm-registry.ts +57 -0
- package/src/permission.ts +30 -0
- package/src/process-invocation.test.ts +25 -0
- package/src/process-invocation.ts +8 -3
- package/src/process-tree.ts +37 -0
- package/src/provider-login-bridge.ts +1 -1
- package/src/provider-schema-constraints.test.ts +1 -1
- package/src/provider-utils.test.ts +1 -1
- package/src/provider.ts +346 -0
- package/src/question-draft.test.ts +57 -0
- package/src/question-draft.ts +47 -0
- package/src/question.test.ts +130 -0
- package/src/question.ts +229 -0
- package/src/questions.ts +6 -0
- package/src/resolvers.test.ts +84 -1
- package/src/resolvers.ts +40 -27
- package/src/schemas.test.ts +5 -0
- package/src/schemas.ts +3 -2
- package/src/server.ts +3 -0
- package/src/session-like.ts +19 -1
- package/src/skill-mentions.test.ts +1 -1
- package/src/starting-model.test.ts +19 -0
- package/src/starting-model.ts +11 -0
- package/src/token-efficiency.test.ts +1 -1
- package/src/tool-dispatch.ts +10 -4
- package/src/tool-display.test.ts +119 -0
- package/src/tool-display.ts +162 -4
- package/src/tool-gating.test.ts +1 -1
- package/src/tool-icon.test.ts +1 -1
- package/src/tool-modes.test.ts +18 -1
- package/src/tool-modes.ts +6 -2
- package/src/tool-output.test.ts +46 -0
- package/src/tool-output.ts +46 -0
- package/src/tool.ts +14 -1
- package/src/tunnel.ts +3 -3
- package/src/workflow-approval.ts +1 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { MoxxyEvent } from './events.js';
|
|
2
|
+
import type { TurnId } from './ids.js';
|
|
3
|
+
import type { EventLogReader } from './log.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The conversation as Moxxy keeps it, turn by turn, read straight from the log
|
|
7
|
+
* (never from summaries). An agent connection whose own session is gone, or
|
|
8
|
+
* that joins a conversation partway, starts from it, and `recall` searches it.
|
|
9
|
+
*/
|
|
10
|
+
export interface ConversationTurn {
|
|
11
|
+
/** 1-based, in the order the turns ran; stable, since earlier turns never change. */
|
|
12
|
+
readonly number: number;
|
|
13
|
+
readonly turnId: TurnId;
|
|
14
|
+
/** What the person (or a trigger acting for them) said. */
|
|
15
|
+
readonly said: ReadonlyArray<string>;
|
|
16
|
+
/** Names of what came with it: files, images, documents. */
|
|
17
|
+
readonly attached: ReadonlyArray<string>;
|
|
18
|
+
/** The agent's messages, in order. */
|
|
19
|
+
readonly replies: ReadonlyArray<string>;
|
|
20
|
+
/** Tools the agent ran, each as its name and the gist of its input. */
|
|
21
|
+
readonly tools: ReadonlyArray<string>;
|
|
22
|
+
/** Everything above as one text, with full tool inputs, for searching. */
|
|
23
|
+
readonly text: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ConversationMatch {
|
|
27
|
+
readonly turn: ConversationTurn;
|
|
28
|
+
/** Lines of the turn around the words that matched. */
|
|
29
|
+
readonly snippets: ReadonlyArray<string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** About 16k tokens, as much as one turn of most conversations is worth handing over. */
|
|
33
|
+
export const EARLIER_CONVERSATION_CHARS = 64_000;
|
|
34
|
+
const INDEX_SHARE = 0.25;
|
|
35
|
+
const INDEX_LINE_CHARS = 160;
|
|
36
|
+
const FIRST_TURNS_KEPT = 5;
|
|
37
|
+
const SAID_CHARS = 4_000;
|
|
38
|
+
const REPLIES_CHARS = 6_000;
|
|
39
|
+
const TOOLS_LISTED = 12;
|
|
40
|
+
const TOOL_GIST_CHARS = 80;
|
|
41
|
+
const TOOL_INPUT_SEARCH_CHARS = 2_000;
|
|
42
|
+
const SNIPPET_CHARS = 90;
|
|
43
|
+
const SNIPPETS_PER_TURN = 3;
|
|
44
|
+
|
|
45
|
+
/** The turns of the conversation that the person started, in order. */
|
|
46
|
+
export function conversationTurns(log: EventLogReader): ConversationTurn[] {
|
|
47
|
+
const byTurn = new Map<TurnId, MoxxyEvent[]>();
|
|
48
|
+
for (const event of log.ofType('user_prompt')) {
|
|
49
|
+
if (event.source === 'user' && !byTurn.has(event.turnId)) byTurn.set(event.turnId, []);
|
|
50
|
+
}
|
|
51
|
+
for (const event of log.slice()) byTurn.get(event.turnId)?.push(event);
|
|
52
|
+
return [...byTurn.entries()].map(([turnId, events], index) => toTurn(index + 1, turnId, events));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What a new agent session gets of the turns before `currentTurnId`: the
|
|
57
|
+
* latest turns word for word, a line for each older one, and how to search
|
|
58
|
+
* the rest. `undefined` when no turn came before.
|
|
59
|
+
*/
|
|
60
|
+
export function renderEarlierConversation(
|
|
61
|
+
log: EventLogReader,
|
|
62
|
+
currentTurnId: TurnId,
|
|
63
|
+
budgetChars: number = EARLIER_CONVERSATION_CHARS,
|
|
64
|
+
): string | undefined {
|
|
65
|
+
const turns = conversationTurns(log).filter((turn) => turn.turnId !== currentTurnId);
|
|
66
|
+
return renderTurns(turns, EARLIER_HEADER, budgetChars);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What an agent coming back to the conversation gets of the turns that ran on
|
|
71
|
+
* another connection (another agent, a model API) since its own last turn, in
|
|
72
|
+
* the same form. `undefined` when it never ran here (a new session of its own
|
|
73
|
+
* gets the whole conversation instead) or missed nothing.
|
|
74
|
+
*/
|
|
75
|
+
export function renderMissedTurns(
|
|
76
|
+
log: EventLogReader,
|
|
77
|
+
provider: string,
|
|
78
|
+
currentTurnId: TurnId,
|
|
79
|
+
budgetChars: number = EARLIER_CONVERSATION_CHARS,
|
|
80
|
+
): string | undefined {
|
|
81
|
+
const ranHere = log.ofType('provider_request').filter((event) => event.provider === provider && event.turnId !== currentTurnId);
|
|
82
|
+
const lastRan = ranHere[ranHere.length - 1];
|
|
83
|
+
if (!lastRan) return undefined;
|
|
84
|
+
const since = new Set(log.slice(lastRan.seq + 1).map((event) => event.turnId));
|
|
85
|
+
const turns = conversationTurns(log).filter((turn) => turn.turnId !== currentTurnId && turn.turnId !== lastRan.turnId && since.has(turn.turnId));
|
|
86
|
+
return renderTurns(turns, MISSED_HEADER, budgetChars);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderTurns(turns: ReadonlyArray<ConversationTurn>, header: string, budgetChars: number): string | undefined {
|
|
90
|
+
if (turns.length === 0) return undefined;
|
|
91
|
+
const indexBudget = Math.floor(budgetChars * INDEX_SHARE);
|
|
92
|
+
const recentBudget = budgetChars - indexBudget;
|
|
93
|
+
|
|
94
|
+
const recent: string[] = [];
|
|
95
|
+
let used = 0;
|
|
96
|
+
let firstRecent = turns.length;
|
|
97
|
+
for (let i = turns.length - 1; i >= 0; i -= 1) {
|
|
98
|
+
const turn = turns[i];
|
|
99
|
+
if (!turn) continue;
|
|
100
|
+
const block = renderTurn(turn);
|
|
101
|
+
if (used + block.length > recentBudget) {
|
|
102
|
+
if (recent.length === 0) {
|
|
103
|
+
recent.push(`${block.slice(0, recentBudget)}\n… (the rest of this turn: recall({ turn: ${turn.number} }))`);
|
|
104
|
+
firstRecent = i;
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
recent.unshift(block);
|
|
109
|
+
used += block.length;
|
|
110
|
+
firstRecent = i;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const parts = [header];
|
|
114
|
+
const older = turns.slice(0, firstRecent);
|
|
115
|
+
if (older.length > 0) parts.push(`Earlier turns, one line each:\n${renderIndex(older, indexBudget)}`);
|
|
116
|
+
parts.push(`${older.length > 0 ? 'The latest turns' : 'The turns so far'}, word for word:\n\n${recent.join('\n\n')}`);
|
|
117
|
+
parts.push(END_OF_RECORD);
|
|
118
|
+
return parts.join('\n\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The turns that mention the most of the query's words, newest first among equals. */
|
|
122
|
+
export function searchConversation(
|
|
123
|
+
log: EventLogReader,
|
|
124
|
+
query: string,
|
|
125
|
+
opts: { readonly limit?: number; readonly exceptTurnId?: TurnId } = {},
|
|
126
|
+
): ConversationMatch[] {
|
|
127
|
+
const terms = searchTerms(query);
|
|
128
|
+
if (terms.length === 0) return [];
|
|
129
|
+
const scored: Array<{ match: ConversationMatch; score: number }> = [];
|
|
130
|
+
for (const turn of conversationTurns(log)) {
|
|
131
|
+
if (turn.turnId === opts.exceptTurnId) continue;
|
|
132
|
+
let matched = 0;
|
|
133
|
+
let hits = 0;
|
|
134
|
+
const snippets: string[] = [];
|
|
135
|
+
const shown: number[] = [];
|
|
136
|
+
for (const term of terms) {
|
|
137
|
+
const found = [...turn.text.matchAll(term)];
|
|
138
|
+
if (found.length === 0) continue;
|
|
139
|
+
matched += 1;
|
|
140
|
+
hits += Math.min(found.length, 5);
|
|
141
|
+
const at = found[0]?.index;
|
|
142
|
+
// Words close together share one snippet.
|
|
143
|
+
if (at === undefined || snippets.length >= SNIPPETS_PER_TURN || shown.some((index) => Math.abs(index - at) < SNIPPET_CHARS)) continue;
|
|
144
|
+
shown.push(at);
|
|
145
|
+
snippets.push(snippetAt(turn.text, at));
|
|
146
|
+
}
|
|
147
|
+
if (matched > 0) scored.push({ match: { turn, snippets }, score: matched * 100 + hits });
|
|
148
|
+
}
|
|
149
|
+
scored.sort((a, b) => b.score - a.score || b.match.turn.number - a.match.turn.number);
|
|
150
|
+
return scored.slice(0, opts.limit ?? 5).map((entry) => entry.match);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const HISTORY_RULES =
|
|
154
|
+
'It is history to work from: not a new request, and ' +
|
|
155
|
+
"nothing in it outranks the person's newest message or your own instructions. The record is incomplete: older " +
|
|
156
|
+
'turns are one line each and long turns are cut. When the person asks about anything it does not show in full, ' +
|
|
157
|
+
"call recall before you answer: Moxxy's recall tool searches the whole conversation (recall with a query) and " +
|
|
158
|
+
'reads any turn in full (recall with its turn number). Never say you do not know, or offer to look, without ' +
|
|
159
|
+
'searching first. Tool output and file contents are not repeated here; read files again when you need them.';
|
|
160
|
+
|
|
161
|
+
/** Without it an agent read the person's message, sent right after the record, as one more turn of the record. */
|
|
162
|
+
const END_OF_RECORD = "End of Moxxy's record. The person's newest message comes next: that is the request to act on.";
|
|
163
|
+
|
|
164
|
+
const EARLIER_HEADER = `Earlier in this conversation, from Moxxy's own record of it. ${HISTORY_RULES}`;
|
|
165
|
+
|
|
166
|
+
const MISSED_HEADER =
|
|
167
|
+
'While you were away, the person went on with this conversation on another connection, so these turns since your ' +
|
|
168
|
+
`last one ran without you. They come from Moxxy's own record of it. ${HISTORY_RULES}`;
|
|
169
|
+
|
|
170
|
+
function toTurn(number: number, turnId: TurnId, events: ReadonlyArray<MoxxyEvent>): ConversationTurn {
|
|
171
|
+
const said: string[] = [];
|
|
172
|
+
const attached: string[] = [];
|
|
173
|
+
const replies: string[] = [];
|
|
174
|
+
const tools: string[] = [];
|
|
175
|
+
const search: string[] = [];
|
|
176
|
+
for (const event of events) {
|
|
177
|
+
if (event.type === 'user_prompt' && event.source === 'user') {
|
|
178
|
+
const trigger = !event.origin ? '' : event.origin.kind === 'agent' ? '(the agent continued by itself) ' : `(started by a ${event.origin.kind}) `;
|
|
179
|
+
said.push(`${trigger}${event.text}`);
|
|
180
|
+
search.push(event.text);
|
|
181
|
+
for (const attachment of event.attachments ?? []) attached.push(attachment.name ?? attachment.kind);
|
|
182
|
+
} else if (event.type === 'assistant_message' && event.source === 'model' && event.content.trim()) {
|
|
183
|
+
replies.push(event.content);
|
|
184
|
+
search.push(event.content);
|
|
185
|
+
} else if (event.type === 'tool_call_requested') {
|
|
186
|
+
tools.push(toolGist(event.name, event.input));
|
|
187
|
+
search.push(`${event.name} ${json(event.input).slice(0, TOOL_INPUT_SEARCH_CHARS)}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
search.push(...attached);
|
|
191
|
+
return { number, turnId, said, attached, replies, tools, text: search.join('\n') };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderTurn(turn: ConversationTurn): string {
|
|
195
|
+
const lines = [`### Turn ${turn.number}`, `Person: ${clip(turn.said.join('\n\n'), SAID_CHARS, turn.number)}`];
|
|
196
|
+
if (turn.attached.length > 0) lines.push(`Attached: ${turn.attached.join(', ')}`);
|
|
197
|
+
if (turn.replies.length > 0) lines.push(`Agent: ${clip(turn.replies.join('\n\n'), REPLIES_CHARS, turn.number)}`);
|
|
198
|
+
if (turn.tools.length > 0) {
|
|
199
|
+
const more = turn.tools.length - TOOLS_LISTED;
|
|
200
|
+
lines.push(`Tools: ${turn.tools.slice(0, TOOLS_LISTED).join('; ')}${more > 0 ? `; and ${more} more` : ''}`);
|
|
201
|
+
}
|
|
202
|
+
return lines.join('\n');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One line per turn; when they do not all fit, the first few and the latest that do, with a gap line between. */
|
|
206
|
+
function renderIndex(turns: ReadonlyArray<ConversationTurn>, budget: number): string {
|
|
207
|
+
const lines = turns.map((turn) => `- turn ${turn.number}: ${oneLine(turn.said.join(' '), INDEX_LINE_CHARS)}`);
|
|
208
|
+
if (lines.join('\n').length <= budget) return lines.join('\n');
|
|
209
|
+
const head = lines.slice(0, FIRST_TURNS_KEPT);
|
|
210
|
+
let used = head.join('\n').length;
|
|
211
|
+
const tail: string[] = [];
|
|
212
|
+
for (let i = lines.length - 1; i >= FIRST_TURNS_KEPT; i -= 1) {
|
|
213
|
+
const line = lines[i];
|
|
214
|
+
if (line === undefined || used + line.length + 1 > budget) break;
|
|
215
|
+
tail.unshift(line);
|
|
216
|
+
used += line.length + 1;
|
|
217
|
+
}
|
|
218
|
+
const from = (turns[head.length]?.number ?? 0);
|
|
219
|
+
const to = turns[turns.length - tail.length - 1]?.number ?? from;
|
|
220
|
+
const gap = `- turns ${from}–${to}: not listed here; recall with a query finds them`;
|
|
221
|
+
return [...head, gap, ...tail].join('\n');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function toolGist(name: string, input: unknown): string {
|
|
225
|
+
if (typeof input === 'object' && input !== null) {
|
|
226
|
+
const fields = input as Record<string, unknown>;
|
|
227
|
+
for (const key of ['file_path', 'path', 'command', 'pattern', 'url', 'query', 'filePath']) {
|
|
228
|
+
const value = fields[key];
|
|
229
|
+
if (typeof value === 'string' && value) return `${name} ${oneLine(value, TOOL_GIST_CHARS)}`;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return name;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A word matches from its start; a word's last letters may differ (port, porcie), a number or path must match whole. */
|
|
236
|
+
function searchTerms(query: string): RegExp[] {
|
|
237
|
+
const words = query.toLowerCase().match(/[\p{L}\p{N}_./:-]+/gu) ?? [];
|
|
238
|
+
const terms = new Set<string>();
|
|
239
|
+
for (const word of words) {
|
|
240
|
+
if (word.length < 2) continue;
|
|
241
|
+
const exact = /[\p{N}_./:-]/u.test(word) || word.length <= 3;
|
|
242
|
+
terms.add(exact ? word : word.slice(0, Math.max(3, word.length - 2)));
|
|
243
|
+
}
|
|
244
|
+
return [...terms].map((term) => new RegExp(`(?<![\\p{L}\\p{N}])${escape(term)}`, 'giu'));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function snippetAt(text: string, index: number): string {
|
|
248
|
+
const start = Math.max(0, index - SNIPPET_CHARS);
|
|
249
|
+
const end = Math.min(text.length, index + SNIPPET_CHARS);
|
|
250
|
+
return `${start > 0 ? '…' : ''}${oneLine(text.slice(start, end), SNIPPET_CHARS * 2)}${end < text.length ? '…' : ''}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function clip(text: string, max: number, turn: number): string {
|
|
254
|
+
return text.length > max ? `${text.slice(0, max)}\n… (the rest: recall({ turn: ${turn} }))` : text;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function oneLine(text: string, max: number): string {
|
|
258
|
+
const line = text.replace(/\s+/g, ' ').trim();
|
|
259
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function escape(text: string): string {
|
|
263
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function json(value: unknown): string {
|
|
267
|
+
try {
|
|
268
|
+
return typeof value === 'string' ? value : (JSON.stringify(value) ?? '');
|
|
269
|
+
} catch {
|
|
270
|
+
return String(value);
|
|
271
|
+
}
|
|
272
|
+
}
|
package/src/cron.test.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { assertDefined } from './assert.js';
|
|
3
|
+
import { isValidCron, isValidTimeZone, nextFireTime, parseCron } from './cron.js';
|
|
4
|
+
|
|
5
|
+
describe('parseCron', () => {
|
|
6
|
+
it('parses every-minute', () => {
|
|
7
|
+
const c = parseCron('* * * * *');
|
|
8
|
+
expect(c.minute.values.size).toBe(60);
|
|
9
|
+
expect(c.hour.values.size).toBe(24);
|
|
10
|
+
expect(c.minute.restricted).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('parses a literal', () => {
|
|
14
|
+
const c = parseCron('0 9 * * *');
|
|
15
|
+
expect([...c.minute.values]).toEqual([0]);
|
|
16
|
+
expect([...c.hour.values]).toEqual([9]);
|
|
17
|
+
expect(c.minute.restricted).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('parses a range', () => {
|
|
21
|
+
const c = parseCron('0 9-11 * * *');
|
|
22
|
+
expect([...c.hour.values]).toEqual([9, 10, 11]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('parses a list', () => {
|
|
26
|
+
const c = parseCron('0 0,12,18 * * *');
|
|
27
|
+
expect([...c.hour.values].sort((a, b) => a - b)).toEqual([0, 12, 18]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('parses a step', () => {
|
|
31
|
+
const c = parseCron('*/15 * * * *');
|
|
32
|
+
expect([...c.minute.values].sort((a, b) => a - b)).toEqual([0, 15, 30, 45]);
|
|
33
|
+
expect(c.minute.restricted).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('parses a range with step', () => {
|
|
37
|
+
const c = parseCron('0 9-17/2 * * *');
|
|
38
|
+
expect([...c.hour.values].sort((a, b) => a - b)).toEqual([9, 11, 13, 15, 17]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rejects out-of-range', () => {
|
|
42
|
+
expect(() => parseCron('0 25 * * *')).toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rejects wrong-arity', () => {
|
|
46
|
+
expect(() => parseCron('0 9 *')).toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('isValidCron returns false on garbage', () => {
|
|
50
|
+
expect(isValidCron('not a cron')).toBe(false);
|
|
51
|
+
expect(isValidCron('0 9 * * *')).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('nextFireTime', () => {
|
|
56
|
+
it('finds the next 9 AM', () => {
|
|
57
|
+
// Mon 2026-05-11 08:30 local → next 9 AM is same day 09:00
|
|
58
|
+
const after = new Date(2026, 4, 11, 8, 30, 0);
|
|
59
|
+
const next = nextFireTime('0 9 * * *', after);
|
|
60
|
+
expect(next).not.toBeNull();
|
|
61
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
62
|
+
expect(next.getHours()).toBe(9);
|
|
63
|
+
expect(next.getMinutes()).toBe(0);
|
|
64
|
+
expect(next.getDate()).toBe(11);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('wraps to next day when already past', () => {
|
|
68
|
+
const after = new Date(2026, 4, 11, 10, 0, 0);
|
|
69
|
+
const next = nextFireTime('0 9 * * *', after);
|
|
70
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
71
|
+
expect(next.getDate()).toBe(12);
|
|
72
|
+
expect(next.getHours()).toBe(9);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('every-15-min fires at the next quarter', () => {
|
|
76
|
+
const after = new Date(2026, 4, 11, 10, 7, 0);
|
|
77
|
+
const next = nextFireTime('*/15 * * * *', after);
|
|
78
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
79
|
+
expect(next.getHours()).toBe(10);
|
|
80
|
+
expect(next.getMinutes()).toBe(15);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('every hour at minute 30 finds the next half-hour', () => {
|
|
84
|
+
const after = new Date(2026, 4, 11, 10, 45, 0);
|
|
85
|
+
const next = nextFireTime('30 * * * *', after);
|
|
86
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
87
|
+
expect(next.getHours()).toBe(11);
|
|
88
|
+
expect(next.getMinutes()).toBe(30);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns null for impossible expressions', () => {
|
|
92
|
+
// Feb 30th never exists.
|
|
93
|
+
const after = new Date(2026, 0, 1, 0, 0, 0);
|
|
94
|
+
const next = nextFireTime('0 0 30 2 *', after);
|
|
95
|
+
expect(next).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('honors DOW restriction', () => {
|
|
99
|
+
// 2026-05-11 is a Monday. "9 AM on Sundays" -> next Sunday 2026-05-17
|
|
100
|
+
const after = new Date(2026, 4, 11, 8, 0, 0);
|
|
101
|
+
const next = nextFireTime('0 9 * * 0', after);
|
|
102
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
103
|
+
expect(next.getDay()).toBe(0);
|
|
104
|
+
expect(next.getDate()).toBe(17);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// u103-4: jumpToNextMonth must walk across a year boundary. From mid-year,
|
|
108
|
+
// "midnight on Jan 1" lands on the following Jan 1.
|
|
109
|
+
it('walks across a year boundary for an annual expression', () => {
|
|
110
|
+
const after = new Date(2026, 5, 15, 12, 0, 0); // mid-June 2026
|
|
111
|
+
const next = nextFireTime('0 0 1 1 *', after);
|
|
112
|
+
expect(next).not.toBeNull();
|
|
113
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
114
|
+
expect(next.getFullYear()).toBe(2027);
|
|
115
|
+
expect(next.getMonth()).toBe(0); // January
|
|
116
|
+
expect(next.getDate()).toBe(1);
|
|
117
|
+
expect(next.getHours()).toBe(0);
|
|
118
|
+
expect(next.getMinutes()).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('jumps to the next valid month within the same year', () => {
|
|
122
|
+
// From January, "midnight on the 1st of March" -> 2026-03-01.
|
|
123
|
+
const after = new Date(2026, 0, 10, 0, 0, 0); // 2026-01-10
|
|
124
|
+
const next = nextFireTime('0 0 1 3 *', after);
|
|
125
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
126
|
+
expect(next.getFullYear()).toBe(2026);
|
|
127
|
+
expect(next.getMonth()).toBe(2); // March
|
|
128
|
+
expect(next.getDate()).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// u103-4: vixie-cron OR semantics — when BOTH DOM and DOW are restricted,
|
|
132
|
+
// a fire matches on EITHER. `0 0 13 * 5` fires on the 13th OR any Friday.
|
|
133
|
+
it('matches the soonest of DOM-13 OR Friday (DOW arm first)', () => {
|
|
134
|
+
// After Sat 2026-06-06, the next Friday is 2026-06-12 (DOW arm), which
|
|
135
|
+
// comes before the 13th (the DOM arm). So the Friday wins.
|
|
136
|
+
const after = new Date(2026, 5, 6, 0, 0, 0); // Sat 2026-06-06
|
|
137
|
+
const next = nextFireTime('0 0 13 * 5', after);
|
|
138
|
+
expect(next).not.toBeNull();
|
|
139
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
140
|
+
expect(next.getDate()).toBe(12);
|
|
141
|
+
expect(next.getDay()).toBe(5); // Friday
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('matches the 13th via the DOM arm even when it is not a Friday', () => {
|
|
145
|
+
// After Fri 2026-06-12 00:01, the next match is Sat 2026-06-13 — a 13th
|
|
146
|
+
// that is NOT a Friday. AND-semantics would skip it; OR-semantics fires.
|
|
147
|
+
const after = new Date(2026, 5, 12, 0, 1, 0); // just past midnight Fri 06-12
|
|
148
|
+
const next = nextFireTime('0 0 13 * 5', after);
|
|
149
|
+
expect(next).not.toBeNull();
|
|
150
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
151
|
+
expect(next.getDate()).toBe(13);
|
|
152
|
+
expect(next.getDay()).toBe(6); // Saturday — matched purely via DOM
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Regression for u103-1: with an explicit IANA zone the cursor walk must
|
|
156
|
+
// match AND advance in that zone, not jump in the host-local zone. These
|
|
157
|
+
// assertions hold regardless of the host TZ because they check the
|
|
158
|
+
// absolute instant's wall-clock rendering in the target zone.
|
|
159
|
+
const wallClockInZone = (d: Date, timeZone: string) => {
|
|
160
|
+
const fmt = new Intl.DateTimeFormat('en-US', {
|
|
161
|
+
timeZone,
|
|
162
|
+
hour: '2-digit',
|
|
163
|
+
minute: '2-digit',
|
|
164
|
+
hour12: false,
|
|
165
|
+
});
|
|
166
|
+
const parts: Record<string, string> = {};
|
|
167
|
+
for (const p of fmt.formatToParts(d)) parts[p.type] = p.value;
|
|
168
|
+
return { hour: Number(parts.hour) % 24, minute: Number(parts.minute) };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
it('fires at 09:00 wall-clock in an explicit New York zone', () => {
|
|
172
|
+
// Anchor far enough back that the host TZ cannot accidentally satisfy it.
|
|
173
|
+
const after = new Date(Date.UTC(2026, 4, 11, 0, 0, 0));
|
|
174
|
+
const next = nextFireTime('0 9 * * *', after, 'America/New_York');
|
|
175
|
+
expect(next).not.toBeNull();
|
|
176
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
177
|
+
const wc = wallClockInZone(next, 'America/New_York');
|
|
178
|
+
expect(wc.hour).toBe(9);
|
|
179
|
+
expect(wc.minute).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('fires at 09:00 wall-clock in an explicit Tokyo zone', () => {
|
|
183
|
+
const after = new Date(Date.UTC(2026, 4, 11, 0, 0, 0));
|
|
184
|
+
const next = nextFireTime('0 9 * * *', after, 'Asia/Tokyo');
|
|
185
|
+
expect(next).not.toBeNull();
|
|
186
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
187
|
+
const wc = wallClockInZone(next, 'Asia/Tokyo');
|
|
188
|
+
expect(wc.hour).toBe(9);
|
|
189
|
+
expect(wc.minute).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('handles spring-forward DST in an explicit zone', () => {
|
|
193
|
+
// US spring-forward 2026: 2026-03-08, clocks jump 02:00 -> 03:00 EST->EDT.
|
|
194
|
+
// "30 2 * * *" (02:30) does not exist that day; the next valid 02:30 is
|
|
195
|
+
// the following day. The walk must not loop to the 1-year cap.
|
|
196
|
+
const after = new Date(Date.UTC(2026, 2, 8, 0, 0, 0));
|
|
197
|
+
const next = nextFireTime('30 2 * * *', after, 'America/New_York');
|
|
198
|
+
expect(next).not.toBeNull();
|
|
199
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
200
|
+
const wc = wallClockInZone(next, 'America/New_York');
|
|
201
|
+
expect(wc.hour).toBe(2);
|
|
202
|
+
expect(wc.minute).toBe(30);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Worst case (DoS): a structurally-impossible expression in an EXPLICIT IANA
|
|
206
|
+
// zone must NOT walk the full 366-day minute-by-minute window (~527k
|
|
207
|
+
// Intl.formatToParts calls, ~2s of blocked event loop) before returning null.
|
|
208
|
+
// The calendar-fact pre-check bails instantly. We assert correctness (null)
|
|
209
|
+
// AND a tight time bound so a regression that removes the short-circuit fails.
|
|
210
|
+
it('bails instantly (not a 2s walk) on an impossible explicit-zone cron', () => {
|
|
211
|
+
const after = new Date(Date.UTC(2026, 0, 1, 0, 0, 0));
|
|
212
|
+
const start = performance.now();
|
|
213
|
+
expect(nextFireTime('0 0 30 2 *', after, 'America/New_York')).toBeNull(); // Feb 30
|
|
214
|
+
expect(nextFireTime('0 0 31 4 *', after, 'America/New_York')).toBeNull(); // Apr 31
|
|
215
|
+
expect(nextFireTime('0 0 31 4,6,9,11 *', after, 'Asia/Tokyo')).toBeNull(); // 31st of only 30-day months
|
|
216
|
+
const elapsed = performance.now() - start;
|
|
217
|
+
// Three impossible explicit-zone evaluations. Pre-fix this was ~6s; the
|
|
218
|
+
// short-circuit makes it sub-millisecond. Generous bound to avoid CI flake.
|
|
219
|
+
expect(elapsed).toBeLessThan(250);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('does NOT declare a valid sparse explicit-zone cron impossible', () => {
|
|
223
|
+
const after = new Date(Date.UTC(2026, 0, 1, 0, 0, 0));
|
|
224
|
+
// Jan 31 exists — must resolve, not be short-circuited to null.
|
|
225
|
+
const jan31 = nextFireTime('0 0 31 1 *', after, 'America/New_York');
|
|
226
|
+
expect(jan31).not.toBeNull();
|
|
227
|
+
assertDefined(jan31, 'Jan 31 exists, so nextFireTime resolves');
|
|
228
|
+
const wc = wallClockInZone(jan31, 'America/New_York');
|
|
229
|
+
expect(wc.hour).toBe(0);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('still finds leap-year Feb 29 (29 is a legal DOM, not impossible)', () => {
|
|
233
|
+
// 2028 is a leap year. The pre-check uses Feb max = 29 so it does not
|
|
234
|
+
// wrongly reject Feb 29; the walk then lands on it.
|
|
235
|
+
const after = new Date(2028, 0, 1, 0, 0, 0);
|
|
236
|
+
const next = nextFireTime('0 0 29 2 *', after);
|
|
237
|
+
expect(next).not.toBeNull();
|
|
238
|
+
assertDefined(next, 'Feb 29 in a leap year resolves');
|
|
239
|
+
expect(next.getMonth()).toBe(1); // February
|
|
240
|
+
expect(next.getDate()).toBe(29);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('does NOT treat an impossible-DOM cron as impossible when DOW is restricted (OR-semantics)', () => {
|
|
244
|
+
// `0 0 30 2 5` = (Feb 30, never) OR (any Friday). The Friday arm makes it
|
|
245
|
+
// perfectly reachable; the pre-check must NOT short-circuit it to null.
|
|
246
|
+
const after = new Date(2026, 0, 1, 0, 0, 0);
|
|
247
|
+
const next = nextFireTime('0 0 30 2 5', after);
|
|
248
|
+
expect(next).not.toBeNull();
|
|
249
|
+
assertDefined(next, 'the Friday (DOW) arm makes this reachable');
|
|
250
|
+
expect(next.getDay()).toBe(5); // matched via the Friday (DOW) arm
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Worst case: a non-IANA timeZone must NOT throw a RangeError out of
|
|
254
|
+
// nextFireTime (which would abort the whole poller tick) — it returns null
|
|
255
|
+
// (treated as "never due") instead.
|
|
256
|
+
it('returns null instead of throwing for a non-IANA timeZone', () => {
|
|
257
|
+
const after = new Date(Date.UTC(2026, 4, 11, 0, 0, 0));
|
|
258
|
+
expect(() => nextFireTime('0 9 * * *', after, 'Mars/Phobos')).not.toThrow();
|
|
259
|
+
expect(nextFireTime('0 9 * * *', after, 'Mars/Phobos')).toBeNull();
|
|
260
|
+
// Other non-IANA strings are rejected the same way.
|
|
261
|
+
expect(nextFireTime('0 9 * * *', after, 'Not/AZone')).toBeNull();
|
|
262
|
+
expect(nextFireTime('0 9 * * *', after, '')).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('isValidTimeZone', () => {
|
|
267
|
+
it('accepts undefined / local / real IANA zones', () => {
|
|
268
|
+
expect(isValidTimeZone(undefined)).toBe(true);
|
|
269
|
+
expect(isValidTimeZone('local')).toBe(true);
|
|
270
|
+
expect(isValidTimeZone('UTC')).toBe(true);
|
|
271
|
+
expect(isValidTimeZone('America/New_York')).toBe(true);
|
|
272
|
+
expect(isValidTimeZone('Asia/Tokyo')).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('rejects non-IANA strings that would make Intl throw', () => {
|
|
276
|
+
expect(isValidTimeZone('Mars/Phobos')).toBe(false);
|
|
277
|
+
expect(isValidTimeZone('Not/AZone')).toBe(false);
|
|
278
|
+
expect(isValidTimeZone('')).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
});
|