github-issue-tower-defence-management 1.146.4 → 1.146.6

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 (72) hide show
  1. package/.github/dependabot.yml +2 -0
  2. package/.github/workflows/codeql.yml +2 -2
  3. package/.github/workflows/commit-lint.yml +20 -0
  4. package/.github/workflows/umino-project.yml +0 -1
  5. package/CHANGELOG.md +14 -0
  6. package/bin/adapter/entry-points/cli/index.js +2 -2
  7. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  8. package/bin/adapter/entry-points/console/ui-dist/assets/index-CPY6wV8C.js +82 -0
  9. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  10. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +26 -100
  11. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
  12. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +49 -576
  13. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  14. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +4 -3
  15. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
  16. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +4 -3
  17. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
  18. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +3 -2
  19. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  20. package/bin/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.js +1 -1
  21. package/bin/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.js.map +1 -1
  22. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js +73 -2
  23. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js.map +1 -1
  24. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +31 -7
  25. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  26. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +3 -1
  27. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
  28. package/bin/domain/usecases/OauthTokenSelectUseCase.js +13 -4
  29. package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
  30. package/package.json +5 -7
  31. package/renovate.json +5 -0
  32. package/src/adapter/entry-points/cli/index.ts +2 -2
  33. package/src/adapter/entry-points/console/consoleGithubTokenResolver.test.ts +22 -20
  34. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +2 -2
  35. package/src/adapter/entry-points/console/ui-dist/assets/index-CPY6wV8C.js +82 -0
  36. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  37. package/src/adapter/entry-points/console/webServer.test.ts +2 -2
  38. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +31 -3
  39. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +68 -3
  40. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +7 -4
  41. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +4 -1
  42. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +6 -2
  43. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +8 -2
  44. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -0
  45. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +14 -2
  46. package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.ts +4 -1
  47. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +157 -15
  48. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.ts +115 -1
  49. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +35 -4
  50. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +77 -5
  51. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +10 -1
  52. package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +39 -8
  53. package/src/domain/usecases/OauthTokenSelectUseCase.ts +25 -5
  54. package/src/domain/usecases/ProjectRequiredFieldCreateUseCase.test.ts +3 -3
  55. package/tsconfig.json +0 -5
  56. package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
  57. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  58. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +3 -1
  59. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
  60. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts +3 -2
  61. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
  62. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +1 -0
  63. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  64. package/types/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.d.ts.map +1 -1
  65. package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts +14 -1
  66. package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts.map +1 -1
  67. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  68. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -1
  69. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +4 -1
  70. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
  71. package/bin/adapter/entry-points/console/ui-dist/assets/index-Bmc_nF1d.js +0 -82
  72. package/src/adapter/entry-points/console/ui-dist/assets/index-Bmc_nF1d.js +0 -82
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-Bmc_nF1d.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-CPY6wV8C.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-CKX2ny-z.css">
9
9
  </head>
10
10
  <body>
@@ -651,11 +651,11 @@ describe('webServer new routes integration', () => {
651
651
  const response = await request(
652
652
  server,
653
653
  'GET',
654
- `/api/itembody?k=${testToken}&url=https://github.com/meta-site/hr-audit-mock/issues/178`,
654
+ `/api/itembody?k=${testToken}&url=https://github.com/acme-labs/acme-portal-mock/issues/178`,
655
655
  );
656
656
  expect(JSON.parse(response.body)).toEqual({ body: 'owner token body' });
657
657
  expect(resolvedUrls).toEqual([
658
- 'https://github.com/meta-site/hr-audit-mock/issues/178',
658
+ 'https://github.com/acme-labs/acme-portal-mock/issues/178',
659
659
  ]);
660
660
  expect(
661
661
  defaultIssueRepository.getIssueOrPullRequestBody,
@@ -1,5 +1,4 @@
1
1
  import YAML from 'yaml';
2
- import TYPIA from 'typia';
3
2
  import fs from 'fs';
4
3
  import { LocalStorageRepository } from '../../repositories/LocalStorageRepository';
5
4
  import { GraphqlProjectRepository } from '../../repositories/GraphqlProjectRepository';
@@ -38,9 +37,38 @@ export class GetStoryObjectMapUseCaseHandler {
38
37
  };
39
38
  };
40
39
 
41
- if (!TYPIA.is<inputType>(input)) {
40
+ const isInputType = (v: unknown): v is inputType => {
41
+ if (typeof v !== 'object' || v === null) return false;
42
+ if (!('projectName' in v) || typeof v.projectName !== 'string')
43
+ return false;
44
+ if (
45
+ !('credentials' in v) ||
46
+ typeof v.credentials !== 'object' ||
47
+ v.credentials === null
48
+ )
49
+ return false;
50
+ const credentials = v.credentials;
51
+ if (
52
+ !('bot' in credentials) ||
53
+ typeof credentials.bot !== 'object' ||
54
+ credentials.bot === null
55
+ )
56
+ return false;
57
+ const bot = credentials.bot;
58
+ if (
59
+ !('github' in bot) ||
60
+ typeof bot.github !== 'object' ||
61
+ bot.github === null
62
+ )
63
+ return false;
64
+ const github = bot.github;
65
+ if (!('token' in github) || typeof github.token !== 'string')
66
+ return false;
67
+ return true;
68
+ };
69
+ if (!isInputType(input)) {
42
70
  throw new Error(
43
- `Invalid input: ${JSON.stringify(input)}\n\n${JSON.stringify(TYPIA.validate<inputType>(input))}`,
71
+ `Invalid input: required fields projectName and credentials.bot.github.token must be strings. Got: ${JSON.stringify(input)}`,
44
72
  );
45
73
  }
46
74
  const localStorageRepository = new LocalStorageRepository();
@@ -1,5 +1,4 @@
1
1
  import YAML from 'yaml';
2
- import TYPIA from 'typia';
3
2
  import fs from 'fs';
4
3
  import os from 'os';
5
4
  import path from 'path';
@@ -175,9 +174,75 @@ export class HandleScheduledEventUseCaseHandler {
175
174
  };
176
175
  };
177
176
 
178
- if (!TYPIA.is<inputType>(input)) {
177
+ const isInputType = (v: unknown): v is inputType => {
178
+ if (typeof v !== 'object' || v === null) return false;
179
+ if (
180
+ !('credentials' in v) ||
181
+ typeof v.credentials !== 'object' ||
182
+ v.credentials === null
183
+ )
184
+ return false;
185
+ const credentials = v.credentials;
186
+ if (
187
+ !('manager' in credentials) ||
188
+ typeof credentials.manager !== 'object' ||
189
+ credentials.manager === null
190
+ )
191
+ return false;
192
+ const manager = credentials.manager;
193
+ if (
194
+ !('github' in manager) ||
195
+ typeof manager.github !== 'object' ||
196
+ manager.github === null
197
+ )
198
+ return false;
199
+ if (
200
+ !('token' in manager.github) ||
201
+ typeof manager.github.token !== 'string'
202
+ )
203
+ return false;
204
+ if (
205
+ !('slack' in manager) ||
206
+ typeof manager.slack !== 'object' ||
207
+ manager.slack === null
208
+ )
209
+ return false;
210
+ if (
211
+ !('userToken' in manager.slack) ||
212
+ typeof manager.slack.userToken !== 'string'
213
+ )
214
+ return false;
215
+ if (
216
+ !('googleServiceAccount' in manager) ||
217
+ typeof manager.googleServiceAccount !== 'object' ||
218
+ manager.googleServiceAccount === null
219
+ )
220
+ return false;
221
+ if (
222
+ !('serviceAccountKey' in manager.googleServiceAccount) ||
223
+ typeof manager.googleServiceAccount.serviceAccountKey !== 'string'
224
+ )
225
+ return false;
226
+ if (
227
+ !('bot' in credentials) ||
228
+ typeof credentials.bot !== 'object' ||
229
+ credentials.bot === null
230
+ )
231
+ return false;
232
+ const bot = credentials.bot;
233
+ if (
234
+ !('github' in bot) ||
235
+ typeof bot.github !== 'object' ||
236
+ bot.github === null
237
+ )
238
+ return false;
239
+ if (!('token' in bot.github) || typeof bot.github.token !== 'string')
240
+ return false;
241
+ return true;
242
+ };
243
+ if (!isInputType(input)) {
179
244
  throw new Error(
180
- `Invalid input: ${JSON.stringify(input)}\n\n${JSON.stringify(TYPIA.validate<inputType>(input))}`,
245
+ `Invalid input: required credential fields are missing. Got: ${JSON.stringify(input)}`,
181
246
  );
182
247
  }
183
248
  if (input.disabled) {
@@ -109,10 +109,12 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
109
109
 
110
110
  const buildHandler = (
111
111
  sessions: ClaudeLiveSession[],
112
+ random: () => number = () => 0.5,
112
113
  ): LiveSessionOauthTokenSelectHandler =>
113
114
  new LiveSessionOauthTokenSelectHandler(
114
115
  new LiveSessionOauthTokenSelectUseCase(),
115
116
  new FakeClaudeLiveSessionRepository(sessions),
117
+ random,
116
118
  );
117
119
 
118
120
  const writeFableRejectionCache = (token: string, resetsAt: number): void => {
@@ -163,7 +165,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
163
165
  );
164
166
  });
165
167
 
166
- it('selects the eligible token with the fewest live sessions', () => {
168
+ it('selects the eligible token with fewer live sessions for a draw inside its weight slice', () => {
167
169
  writeTokenList([
168
170
  { name: 'busy', token: 'fake-busy' },
169
171
  { name: 'idle', token: 'fake-idle' },
@@ -181,9 +183,10 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
181
183
  sevenDayReset: NOW + 6 * DAY,
182
184
  });
183
185
 
184
- const handler = buildHandler([
185
- { token: 'fake-busy', sessionKey: 'session-a' },
186
- ]);
186
+ const handler = buildHandler(
187
+ [{ token: 'fake-busy', sessionKey: 'session-a' }],
188
+ () => 0.9,
189
+ );
187
190
  const output = handler.handle({
188
191
  tokenListJsonPath: tokenListPath,
189
192
  cacheDirectory,
@@ -8,6 +8,7 @@ import {
8
8
  FIVE_HOUR_MIN_FREE_RATIO,
9
9
  OauthTokenCandidate,
10
10
  SEVEN_DAY_MIN_FREE_RATIO,
11
+ SelectionRandom,
11
12
  } from '../../../domain/usecases/OauthTokenSelectUseCase';
12
13
  import { ProcClaudeLiveSessionRepository } from '../../repositories/ProcClaudeLiveSessionRepository';
13
14
  import { FABLE_LIMIT_TYPE, readRateLimit } from '../../proxy/RateLimitCache';
@@ -33,6 +34,7 @@ export class LiveSessionOauthTokenSelectHandler {
33
34
  constructor(
34
35
  private readonly useCase: LiveSessionOauthTokenSelectUseCase = new LiveSessionOauthTokenSelectUseCase(),
35
36
  private readonly liveSessionRepository: ClaudeLiveSessionRepository = new ProcClaudeLiveSessionRepository(),
37
+ private readonly random: SelectionRandom = Math.random,
36
38
  ) {}
37
39
 
38
40
  handle = (
@@ -96,6 +98,7 @@ export class LiveSessionOauthTokenSelectHandler {
96
98
  candidates,
97
99
  liveSessions,
98
100
  input.nowEpochSeconds,
101
+ this.random,
99
102
  );
100
103
 
101
104
  return {
@@ -125,7 +128,7 @@ export class LiveSessionOauthTokenSelectHandler {
125
128
  );
126
129
  } else {
127
130
  lines.push(
128
- `Selected ${result.selected.name} (fewest live sessions, then soonest 7d reset among eligible tokens).`,
131
+ `Selected ${result.selected.name} (weighted by how soon the 7d window resets, how much of it is free, and how few live sessions the token carries).`,
129
132
  );
130
133
  }
131
134
 
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
+ import { OauthTokenSelectUseCase } from '../../../domain/usecases/OauthTokenSelectUseCase';
4
5
  import { FABLE_LIMIT_TYPE, hashToken } from '../../proxy/RateLimitCache';
5
6
  import {
6
7
  OauthTokenSelectHandler,
@@ -122,7 +123,7 @@ describe('OauthTokenSelectHandler', () => {
122
123
  );
123
124
  };
124
125
 
125
- it('selects the eligible token with the soonest 7d reset', () => {
126
+ it('selects the token whose 7d window resets soonest for a draw inside its weight slice', () => {
126
127
  writeTokenList([
127
128
  { name: 'far', token: 'fake-far' },
128
129
  { name: 'soon', token: 'fake-soon' },
@@ -140,7 +141,10 @@ describe('OauthTokenSelectHandler', () => {
140
141
  sevenDayReset: NOW + 2 * DAY,
141
142
  });
142
143
 
143
- const handler = new OauthTokenSelectHandler();
144
+ const handler = new OauthTokenSelectHandler(
145
+ new OauthTokenSelectUseCase(),
146
+ () => 0.5,
147
+ );
144
148
  const output = handler.handle({
145
149
  tokenListJsonPath: tokenListPath,
146
150
  cacheDirectory,
@@ -3,6 +3,7 @@ import {
3
3
  OauthTokenCandidate,
4
4
  OauthTokenSelectResult,
5
5
  OauthTokenSelectUseCase,
6
+ SelectionRandom,
6
7
  } from '../../../domain/usecases/OauthTokenSelectUseCase';
7
8
  import {
8
9
  FABLE_LIMIT_TYPE,
@@ -55,6 +56,7 @@ export const resolveCacheDirectory = (
55
56
  export class OauthTokenSelectHandler {
56
57
  constructor(
57
58
  private readonly useCase: OauthTokenSelectUseCase = new OauthTokenSelectUseCase(),
59
+ private readonly random: SelectionRandom = Math.random,
58
60
  ) {}
59
61
 
60
62
  handle = (
@@ -112,7 +114,11 @@ export class OauthTokenSelectHandler {
112
114
  },
113
115
  );
114
116
 
115
- const result = this.useCase.run(candidates, input.nowEpochSeconds);
117
+ const result = this.useCase.run(
118
+ candidates,
119
+ input.nowEpochSeconds,
120
+ this.random,
121
+ );
116
122
 
117
123
  return {
118
124
  selectedToken: result.selected?.token ?? null,
@@ -139,7 +145,7 @@ export class OauthTokenSelectHandler {
139
145
  lines.push('No eligible token passed the rate-limit filter.');
140
146
  } else {
141
147
  lines.push(
142
- `Selected ${result.selected.name} (soonest 7d reset among eligible tokens).`,
148
+ `Selected ${result.selected.name} (weighted by how soon the 7d window resets and how much of it is free among eligible tokens).`,
143
149
  );
144
150
  }
145
151
 
@@ -147,6 +147,7 @@ describe('notifySilentTmuxSessions', () => {
147
147
  messageTemplates: EMPTY_TEMPLATES,
148
148
  now: NOW,
149
149
  ...DEFAULT_NOTIFY_SILENT_TMUX_SESSIONS_PARAMS,
150
+ submitPushOutWaitMilliseconds: 0,
150
151
  });
151
152
 
152
153
  it('sends a main stalled notification to a silent github-named live session that was already a candidate in the previous cycle', async () => {
@@ -18,7 +18,11 @@ import { LocalProcessLiveSessionProcessSnapshotProvider } from '../../repositori
18
18
  import { ProcFsProcessEnvironReader } from '../../repositories/ProcFsProcessEnvironReader';
19
19
  import { FileSystemInteractiveLiveSessionTranscriptResolver } from '../../repositories/FileSystemInteractiveLiveSessionTranscriptResolver';
20
20
  import { FileSystemSessionOutputActivityRepository } from '../../repositories/FileSystemSessionOutputActivityRepository';
21
- import { TmuxSilentSessionNotificationRepository } from '../../repositories/TmuxSilentSessionNotificationRepository';
21
+ import {
22
+ DEFAULT_SUBMIT_PUSH_OUT_ATTEMPT_LIMIT,
23
+ DEFAULT_SUBMIT_PUSH_OUT_WAIT_MILLISECONDS,
24
+ TmuxSilentSessionNotificationRepository,
25
+ } from '../../repositories/TmuxSilentSessionNotificationRepository';
22
26
  import { NoUnansweredOwnerCallStatusProvider } from '../../repositories/NoUnansweredOwnerCallStatusProvider';
23
27
  import { TranscriptOwnerCallStatusProvider } from '../../repositories/TranscriptOwnerCallStatusProvider';
24
28
  import { TranscriptRefusalTailStatusProvider } from '../../repositories/TranscriptRefusalTailStatusProvider';
@@ -57,6 +61,7 @@ export type NotifySilentTmuxSessionsParams = {
57
61
  hubTaskStatusCacheStateFilePath: string | null;
58
62
  hubTaskStatusCacheTtlSeconds: number;
59
63
  messageTemplates: SilentSessionMessageTemplates;
64
+ submitPushOutWaitMilliseconds?: number;
60
65
  now: Date;
61
66
  };
62
67
 
@@ -121,6 +126,7 @@ export const notifySilentTmuxSessions = async (
121
126
  hubTaskStatusCacheStateFilePath,
122
127
  hubTaskStatusCacheTtlSeconds,
123
128
  messageTemplates,
129
+ submitPushOutWaitMilliseconds,
124
130
  now,
125
131
  } = params;
126
132
  if (!enabled) {
@@ -149,7 +155,13 @@ export const notifySilentTmuxSessions = async (
149
155
  now,
150
156
  ),
151
157
  createOwnerCallStatusProvider(ownerCallMarker),
152
- new TmuxSilentSessionNotificationRepository(localCommandRunner),
158
+ new TmuxSilentSessionNotificationRepository(
159
+ localCommandRunner,
160
+ new RealSleeper(),
161
+ DEFAULT_SUBMIT_PUSH_OUT_ATTEMPT_LIMIT,
162
+ submitPushOutWaitMilliseconds ??
163
+ DEFAULT_SUBMIT_PUSH_OUT_WAIT_MILLISECONDS,
164
+ ),
153
165
  candidateDebounceStateFilePath !== null
154
166
  ? new FileSystemSilentSessionCandidateStateRepository(
155
167
  candidateDebounceStateFilePath,
@@ -46,7 +46,10 @@ export const resetDegeneratedTmuxSessions = async (
46
46
  ),
47
47
  new FileSystemInteractiveLiveSessionTranscriptResolver(),
48
48
  new FileSystemSessionAssistantTurnsRepository(),
49
- new TmuxSilentSessionNotificationRepository(localCommandRunner),
49
+ new TmuxSilentSessionNotificationRepository(
50
+ localCommandRunner,
51
+ new RealSleeper(),
52
+ ),
50
53
  new NodeTmuxSessionRepository(localCommandRunner),
51
54
  cooldownStateFilePath !== null
52
55
  ? new FileSystemSessionDegenerationCooldownStateRepository(
@@ -1,4 +1,5 @@
1
1
  import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
2
+ import { Sleeper } from '../../domain/usecases/adapter-interfaces/Sleeper';
2
3
  import { TmuxSilentSessionNotificationRepository } from './TmuxSilentSessionNotificationRepository';
3
4
 
4
5
  type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
@@ -11,14 +12,55 @@ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
11
12
  }),
12
13
  });
13
14
 
15
+ const createMockSleeper = (): Mocked<Sleeper> => ({
16
+ sleep: jest.fn().mockResolvedValue(undefined),
17
+ });
18
+
19
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
20
+
21
+ const paneWithInputBoxContent = (inputBoxContent: string): string =>
22
+ [
23
+ '● The previous assistant output line',
24
+ '',
25
+ '────────────────────────────────────────',
26
+ inputBoxContent,
27
+ '────────────────────────────────────────',
28
+ ' {Opus 4.5} (dev1) CTX[██░░░░░░░░]16%',
29
+ ].join('\n');
30
+
31
+ const createPaneAwareRunner = (
32
+ paneTexts: string[],
33
+ ): Mocked<LocalCommandRunner> => {
34
+ let capturedPaneIndex = 0;
35
+ return {
36
+ runCommand: jest.fn().mockImplementation((_program, args: string[]) => {
37
+ if (args[0] !== 'capture-pane') {
38
+ return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 });
39
+ }
40
+ const paneText =
41
+ paneTexts[Math.min(capturedPaneIndex, paneTexts.length - 1)];
42
+ capturedPaneIndex += 1;
43
+ return Promise.resolve({ stdout: paneText, stderr: '', exitCode: 0 });
44
+ }),
45
+ };
46
+ };
47
+
48
+ const submitKeyCallCount = (runner: Mocked<LocalCommandRunner>): number =>
49
+ runner.runCommand.mock.calls.filter(
50
+ (call) => call[1][0] === 'send-keys' && call[1][3] === 'Enter',
51
+ ).length;
52
+
14
53
  describe('TmuxSilentSessionNotificationRepository', () => {
15
54
  describe('sendSelfCheckNotification', () => {
16
55
  it('wraps the message in bracketed-paste framing, sends it literally, then submits it with Enter', async () => {
17
56
  const runner = createMockRunner();
18
- const repository = new TmuxSilentSessionNotificationRepository(runner);
57
+ const repository = new TmuxSilentSessionNotificationRepository(
58
+ runner,
59
+ createMockSleeper(),
60
+ );
19
61
 
20
62
  await repository.sendSelfCheckNotification(
21
- 'https_//github_com/owner/repo/issues/9',
63
+ sessionName,
22
64
  'self check message',
23
65
  );
24
66
 
@@ -27,26 +69,26 @@ describe('TmuxSilentSessionNotificationRepository', () => {
27
69
  [
28
70
  'send-keys',
29
71
  '-t',
30
- 'https_//github_com/owner/repo/issues/9',
72
+ sessionName,
31
73
  '-l',
32
74
  '\x1b[200~self check message\x1b[201~',
33
75
  ],
34
76
  ]);
35
77
  expect(runner.runCommand.mock.calls[1]).toEqual([
36
78
  'tmux',
37
- ['send-keys', '-t', 'https_//github_com/owner/repo/issues/9', 'Enter'],
79
+ ['send-keys', '-t', sessionName, 'Enter'],
38
80
  ]);
39
81
  });
40
82
 
41
83
  it('flattens a multi-line message into a single line so no newline reaches the input box', async () => {
42
84
  const runner = createMockRunner();
43
- const repository = new TmuxSilentSessionNotificationRepository(runner);
85
+ const repository = new TmuxSilentSessionNotificationRepository(
86
+ runner,
87
+ createMockSleeper(),
88
+ );
44
89
  const multiLineMessage = 'first line\nsecond line\n\nthird line';
45
90
 
46
- await repository.sendSelfCheckNotification(
47
- 'https_//github_com/owner/repo/issues/9',
48
- multiLineMessage,
49
- );
91
+ await repository.sendSelfCheckNotification(sessionName, multiLineMessage);
50
92
 
51
93
  const literalArgument = runner.runCommand.mock.calls[0][1][4];
52
94
  expect(literalArgument).not.toContain('\n');
@@ -58,10 +100,13 @@ describe('TmuxSilentSessionNotificationRepository', () => {
58
100
 
59
101
  it('keeps the bracketed-paste framing around the flattened message', async () => {
60
102
  const runner = createMockRunner();
61
- const repository = new TmuxSilentSessionNotificationRepository(runner);
103
+ const repository = new TmuxSilentSessionNotificationRepository(
104
+ runner,
105
+ createMockSleeper(),
106
+ );
62
107
 
63
108
  await repository.sendSelfCheckNotification(
64
- 'https_//github_com/owner/repo/issues/9',
109
+ sessionName,
65
110
  'first line\nsecond line',
66
111
  );
67
112
 
@@ -71,16 +116,19 @@ describe('TmuxSilentSessionNotificationRepository', () => {
71
116
  expect(literalBytes.endsWith('1b5b3230317e')).toBe(true);
72
117
  expect(runner.runCommand.mock.calls[1]).toEqual([
73
118
  'tmux',
74
- ['send-keys', '-t', 'https_//github_com/owner/repo/issues/9', 'Enter'],
119
+ ['send-keys', '-t', sessionName, 'Enter'],
75
120
  ]);
76
121
  });
77
122
 
78
123
  it('collapses the run of spaces left by consecutive line breaks', async () => {
79
124
  const runner = createMockRunner();
80
- const repository = new TmuxSilentSessionNotificationRepository(runner);
125
+ const repository = new TmuxSilentSessionNotificationRepository(
126
+ runner,
127
+ createMockSleeper(),
128
+ );
81
129
 
82
130
  await repository.sendSelfCheckNotification(
83
- 'https_//github_com/owner/repo/issues/9',
131
+ sessionName,
84
132
  'section one\n\n\nsection two',
85
133
  );
86
134
 
@@ -97,7 +145,10 @@ describe('TmuxSilentSessionNotificationRepository', () => {
97
145
  stderr: "can't find session",
98
146
  exitCode: 1,
99
147
  });
100
- const repository = new TmuxSilentSessionNotificationRepository(runner);
148
+ const repository = new TmuxSilentSessionNotificationRepository(
149
+ runner,
150
+ createMockSleeper(),
151
+ );
101
152
 
102
153
  await expect(
103
154
  repository.sendSelfCheckNotification('missing_session', 'message'),
@@ -105,5 +156,96 @@ describe('TmuxSilentSessionNotificationRepository', () => {
105
156
  'Failed to send notification to tmux session "missing_session"',
106
157
  );
107
158
  });
159
+
160
+ it('pushes the message out with another Enter while it is still sitting in the input box', async () => {
161
+ const runner = createPaneAwareRunner([
162
+ paneWithInputBoxContent(
163
+ '❯ [TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
164
+ ),
165
+ paneWithInputBoxContent('❯ '),
166
+ ]);
167
+ const repository = new TmuxSilentSessionNotificationRepository(
168
+ runner,
169
+ createMockSleeper(),
170
+ );
171
+
172
+ await repository.sendSelfCheckNotification(
173
+ sessionName,
174
+ '[TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
175
+ );
176
+
177
+ expect(submitKeyCallCount(runner)).toBe(2);
178
+ expect(runner.runCommand.mock.calls[2]).toEqual([
179
+ 'tmux',
180
+ ['capture-pane', '-p', '-t', sessionName],
181
+ ]);
182
+ });
183
+
184
+ it('sends no further Enter once the input box no longer holds the message', async () => {
185
+ const runner = createPaneAwareRunner([paneWithInputBoxContent('❯ ')]);
186
+ const repository = new TmuxSilentSessionNotificationRepository(
187
+ runner,
188
+ createMockSleeper(),
189
+ );
190
+
191
+ await repository.sendSelfCheckNotification(
192
+ sessionName,
193
+ '[TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
194
+ );
195
+
196
+ expect(submitKeyCallCount(runner)).toBe(1);
197
+ });
198
+
199
+ it('sends no further Enter while the message is held as a queued message', async () => {
200
+ const runner = createPaneAwareRunner([
201
+ paneWithInputBoxContent('❯ Press up to edit queued messages'),
202
+ ]);
203
+ const repository = new TmuxSilentSessionNotificationRepository(
204
+ runner,
205
+ createMockSleeper(),
206
+ );
207
+
208
+ await repository.sendSelfCheckNotification(
209
+ sessionName,
210
+ '[TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
211
+ );
212
+
213
+ expect(submitKeyCallCount(runner)).toBe(1);
214
+ });
215
+
216
+ it('stops pushing after the configured attempt limit so newlines cannot pile up', async () => {
217
+ const runner = createPaneAwareRunner([
218
+ paneWithInputBoxContent(
219
+ '❯ [TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
220
+ ),
221
+ ]);
222
+ const repository = new TmuxSilentSessionNotificationRepository(
223
+ runner,
224
+ createMockSleeper(),
225
+ 2,
226
+ );
227
+
228
+ await repository.sendSelfCheckNotification(
229
+ sessionName,
230
+ '[TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
231
+ );
232
+
233
+ expect(submitKeyCallCount(runner)).toBe(3);
234
+ });
235
+
236
+ it('waits for the bracketed-paste parsing and rendering window before reading the input box back', async () => {
237
+ const runner = createPaneAwareRunner([paneWithInputBoxContent('❯ ')]);
238
+ const sleeper = createMockSleeper();
239
+ const repository = new TmuxSilentSessionNotificationRepository(
240
+ runner,
241
+ sleeper,
242
+ 3,
243
+ 2500,
244
+ );
245
+
246
+ await repository.sendSelfCheckNotification(sessionName, 'self check');
247
+
248
+ expect(sleeper.sleep).toHaveBeenCalledWith(2500);
249
+ });
108
250
  });
109
251
  });