github-issue-tower-defence-management 1.141.0 → 1.142.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/CHANGELOG.md +13 -0
- package/README.md +6 -6
- package/bin/adapter/entry-points/console/dashboardComposeService.js +23 -4
- package/bin/adapter/entry-points/console/dashboardComposeService.js.map +1 -1
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +1 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
- package/bin/adapter/repositories/FileSystemSubAgentLivenessResolver.js +3 -3
- package/bin/adapter/repositories/FileSystemSubAgentLivenessResolver.js.map +1 -1
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +23 -9
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
- package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js +41 -5
- package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js.map +1 -1
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +16 -1
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/dashboardComposeService.test.ts +82 -0
- package/src/adapter/entry-points/console/dashboardComposeService.ts +38 -4
- package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +6 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +4 -0
- package/src/adapter/repositories/FileSystemSubAgentLivenessResolver.test.ts +94 -0
- package/src/adapter/repositories/FileSystemSubAgentLivenessResolver.ts +3 -3
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +368 -1
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +28 -10
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +31 -0
- package/src/domain/usecases/dashboard/ComposeDashboardUseCase.test.ts +164 -0
- package/src/domain/usecases/dashboard/ComposeDashboardUseCase.ts +63 -5
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +73 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +23 -0
- package/types/adapter/entry-points/console/dashboardComposeService.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts +2 -1
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
- package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +2 -1
- package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
- package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts +3 -1
- package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts.map +1 -1
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +6 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -1
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as os from 'os';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
TranscriptOwnerCallStatusProvider,
|
|
6
|
+
ownerCallMarkerFamilyResolve,
|
|
7
|
+
} from './TranscriptOwnerCallStatusProvider';
|
|
5
8
|
import { SILENT_SESSION_REMINDER_SENTINEL } from '../../domain/usecases/silentSessionReminderSentinel';
|
|
9
|
+
import {
|
|
10
|
+
NotifySilentLiveSessionsUseCase,
|
|
11
|
+
DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
|
|
12
|
+
DEFAULT_UNANSWERED_OWNER_CALL_GRACE_SECONDS,
|
|
13
|
+
DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
|
|
14
|
+
DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
|
|
15
|
+
DEFAULT_NOTIFICATION_STAGGER_SECONDS,
|
|
16
|
+
DEFAULT_CANDIDATE_DEBOUNCE_RECENCY_WINDOW_SECONDS,
|
|
17
|
+
DEFAULT_HUB_TASK_STATUS_CACHE_TTL_SECONDS,
|
|
18
|
+
} from '../../domain/usecases/NotifySilentLiveSessionsUseCase';
|
|
6
19
|
|
|
7
20
|
describe('TranscriptOwnerCallStatusProvider', () => {
|
|
8
21
|
let rootDirectory: string;
|
|
@@ -122,6 +135,8 @@ describe('TranscriptOwnerCallStatusProvider', () => {
|
|
|
122
135
|
|
|
123
136
|
const sessionName = 'workbench';
|
|
124
137
|
|
|
138
|
+
const TERMINATORLESS_MARKER = 'OWNER-CALL';
|
|
139
|
+
|
|
125
140
|
it('reports a session as waiting when the last owner call is newer than the last owner reply', async () => {
|
|
126
141
|
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
127
142
|
ownerReply('2026-06-27T10:00:00.000Z'),
|
|
@@ -384,6 +399,182 @@ describe('TranscriptOwnerCallStatusProvider', () => {
|
|
|
384
399
|
expect(result.has(sessionName)).toBe(true);
|
|
385
400
|
});
|
|
386
401
|
|
|
402
|
+
it('detects an owner call raised with the candidate tag family while the configured marker names the legacy literal family', async () => {
|
|
403
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
404
|
+
ownerReply('2026-06-27T10:00:00.000Z'),
|
|
405
|
+
{
|
|
406
|
+
type: 'assistant',
|
|
407
|
+
timestamp: '2026-06-27T10:05:00.000Z',
|
|
408
|
+
message: {
|
|
409
|
+
role: 'assistant',
|
|
410
|
+
content: [
|
|
411
|
+
{
|
|
412
|
+
type: 'text',
|
|
413
|
+
text: '<call-to-user-pending>Please decide.</call-to-user-pending>',
|
|
414
|
+
},
|
|
415
|
+
],
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
]);
|
|
419
|
+
const provider = new TranscriptOwnerCallStatusProvider('<call-to-user>');
|
|
420
|
+
|
|
421
|
+
const result =
|
|
422
|
+
await provider.listUnansweredOwnerCallEpochSecondsBySessionName(
|
|
423
|
+
new Map([[sessionName, transcriptPath]]),
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
expect(result.get(sessionName)).toBe(
|
|
427
|
+
Math.floor(Date.parse('2026-06-27T10:05:00.000Z') / 1000),
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('treats a later genuine owner reply as answering a candidate-tag owner call', async () => {
|
|
432
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
433
|
+
{
|
|
434
|
+
type: 'assistant',
|
|
435
|
+
timestamp: '2026-06-27T10:00:00.000Z',
|
|
436
|
+
message: {
|
|
437
|
+
role: 'assistant',
|
|
438
|
+
content: [
|
|
439
|
+
{
|
|
440
|
+
type: 'text',
|
|
441
|
+
text: '<call-to-user-pending>Decide.</call-to-user-pending>',
|
|
442
|
+
},
|
|
443
|
+
],
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
ownerReply('2026-06-27T10:05:00.000Z'),
|
|
447
|
+
]);
|
|
448
|
+
const provider = new TranscriptOwnerCallStatusProvider('<call-to-user>');
|
|
449
|
+
|
|
450
|
+
const result =
|
|
451
|
+
await provider.listUnansweredOwnerCallEpochSecondsBySessionName(
|
|
452
|
+
new Map([[sessionName, transcriptPath]]),
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
expect(result.has(sessionName)).toBe(false);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('keeps detecting an owner call raised with the legacy literal tag family', async () => {
|
|
459
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
460
|
+
ownerReply('2026-06-27T10:00:00.000Z'),
|
|
461
|
+
{
|
|
462
|
+
type: 'assistant',
|
|
463
|
+
timestamp: '2026-06-27T10:05:00.000Z',
|
|
464
|
+
message: {
|
|
465
|
+
role: 'assistant',
|
|
466
|
+
content: [
|
|
467
|
+
{
|
|
468
|
+
type: 'text',
|
|
469
|
+
text: '<call-to-user>Please decide.</call-to-user>',
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
]);
|
|
475
|
+
const provider = new TranscriptOwnerCallStatusProvider('<call-to-user>');
|
|
476
|
+
|
|
477
|
+
const result =
|
|
478
|
+
await provider.listUnansweredOwnerCallEpochSecondsBySessionName(
|
|
479
|
+
new Map([[sessionName, transcriptPath]]),
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
expect(result.get(sessionName)).toBe(
|
|
483
|
+
Math.floor(Date.parse('2026-06-27T10:05:00.000Z') / 1000),
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('reports the latest call when the legacy tag precedes a candidate tag', async () => {
|
|
488
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
489
|
+
{
|
|
490
|
+
type: 'assistant',
|
|
491
|
+
timestamp: '2026-06-27T09:00:00.000Z',
|
|
492
|
+
message: {
|
|
493
|
+
role: 'assistant',
|
|
494
|
+
content: [
|
|
495
|
+
{ type: 'text', text: '<call-to-user>First.</call-to-user>' },
|
|
496
|
+
],
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
type: 'assistant',
|
|
501
|
+
timestamp: '2026-06-27T10:05:00.000Z',
|
|
502
|
+
message: {
|
|
503
|
+
role: 'assistant',
|
|
504
|
+
content: [
|
|
505
|
+
{
|
|
506
|
+
type: 'text',
|
|
507
|
+
text: '<call-to-user-pending>Second.</call-to-user-pending>',
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
]);
|
|
513
|
+
const provider = new TranscriptOwnerCallStatusProvider('<call-to-user>');
|
|
514
|
+
|
|
515
|
+
const result =
|
|
516
|
+
await provider.listUnansweredOwnerCallEpochSecondsBySessionName(
|
|
517
|
+
new Map([[sessionName, transcriptPath]]),
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
expect(result.get(sessionName)).toBe(
|
|
521
|
+
Math.floor(Date.parse('2026-06-27T10:05:00.000Z') / 1000),
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('does not treat an unrelated tag that merely shares the marker prefix as an owner call', async () => {
|
|
526
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
527
|
+
{
|
|
528
|
+
type: 'assistant',
|
|
529
|
+
timestamp: '2026-06-27T10:05:00.000Z',
|
|
530
|
+
message: {
|
|
531
|
+
role: 'assistant',
|
|
532
|
+
content: [
|
|
533
|
+
{
|
|
534
|
+
type: 'text',
|
|
535
|
+
text: '<call-to-user-draft>Not a call.</call-to-user-draft>',
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
]);
|
|
541
|
+
const provider = new TranscriptOwnerCallStatusProvider('<call-to-user>');
|
|
542
|
+
|
|
543
|
+
const result =
|
|
544
|
+
await provider.listUnansweredOwnerCallEpochSecondsBySessionName(
|
|
545
|
+
new Map([[sessionName, transcriptPath]]),
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
expect(result.has(sessionName)).toBe(false);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('treats a marker that does not end with the tag terminator as an owner call marker in its own right', async () => {
|
|
552
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
553
|
+
{
|
|
554
|
+
type: 'assistant',
|
|
555
|
+
timestamp: '2026-06-27T10:05:00.000Z',
|
|
556
|
+
message: {
|
|
557
|
+
role: 'assistant',
|
|
558
|
+
content: [
|
|
559
|
+
{ type: 'text', text: `${TERMINATORLESS_MARKER} please decide` },
|
|
560
|
+
],
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
]);
|
|
564
|
+
const provider = new TranscriptOwnerCallStatusProvider(
|
|
565
|
+
TERMINATORLESS_MARKER,
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
const result =
|
|
569
|
+
await provider.listUnansweredOwnerCallEpochSecondsBySessionName(
|
|
570
|
+
new Map([[sessionName, transcriptPath]]),
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
expect(result.get(sessionName)).toBe(
|
|
574
|
+
Math.floor(Date.parse('2026-06-27T10:05:00.000Z') / 1000),
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
|
|
387
578
|
it('uses the canonical call-to-user marker string', async () => {
|
|
388
579
|
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
389
580
|
{
|
|
@@ -405,3 +596,179 @@ describe('TranscriptOwnerCallStatusProvider', () => {
|
|
|
405
596
|
expect(result.has(sessionName)).toBe(true);
|
|
406
597
|
});
|
|
407
598
|
});
|
|
599
|
+
|
|
600
|
+
describe('ownerCallMarkerFamilyResolve', () => {
|
|
601
|
+
const TERMINATORLESS_MARKER = 'OWNER-CALL';
|
|
602
|
+
const CLOSED_TAG_MARKER = `<${TERMINATORLESS_MARKER}>`;
|
|
603
|
+
|
|
604
|
+
it('resolves a closed-tag marker to that tag and its candidate form', () => {
|
|
605
|
+
expect(ownerCallMarkerFamilyResolve(CLOSED_TAG_MARKER)).toEqual([
|
|
606
|
+
CLOSED_TAG_MARKER,
|
|
607
|
+
`<${TERMINATORLESS_MARKER}-pending>`,
|
|
608
|
+
]);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('resolves the deployed legacy owner-call tag to both tag families', () => {
|
|
612
|
+
expect(ownerCallMarkerFamilyResolve('<call-to-user>')).toEqual([
|
|
613
|
+
'<call-to-user>',
|
|
614
|
+
'<call-to-user-pending>',
|
|
615
|
+
]);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('resolves a marker that does not end with the tag terminator to that marker alone', () => {
|
|
619
|
+
expect(ownerCallMarkerFamilyResolve(TERMINATORLESS_MARKER)).toEqual([
|
|
620
|
+
TERMINATORLESS_MARKER,
|
|
621
|
+
]);
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
describe('TranscriptOwnerCallStatusProvider wired into NotifySilentLiveSessionsUseCase', () => {
|
|
626
|
+
let rootDirectory: string;
|
|
627
|
+
|
|
628
|
+
beforeEach(() => {
|
|
629
|
+
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
630
|
+
rootDirectory = fs.mkdtempSync(
|
|
631
|
+
path.join(os.tmpdir(), 'owner-call-suppression-'),
|
|
632
|
+
);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
afterEach(() => {
|
|
636
|
+
jest.restoreAllMocks();
|
|
637
|
+
fs.rmSync(rootDirectory, { force: true, recursive: true });
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
const now = new Date('2026-06-27T11:00:00.000Z');
|
|
641
|
+
const nowEpochSeconds = Math.floor(now.getTime() / 1000);
|
|
642
|
+
const monitoredSessionName =
|
|
643
|
+
'https_//github_com/HiromiShikata/repo/issues/42';
|
|
644
|
+
|
|
645
|
+
class EveryNameRecentSet extends Set<string> {
|
|
646
|
+
override has = (): boolean => true;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const assistantEntryWith = (text: string): object => ({
|
|
650
|
+
type: 'assistant',
|
|
651
|
+
timestamp: '2026-06-27T10:30:00.000Z',
|
|
652
|
+
message: {
|
|
653
|
+
role: 'assistant',
|
|
654
|
+
stop_reason: 'end_turn',
|
|
655
|
+
content: [{ type: 'text', text }],
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const runUseCaseWithTranscript = async (
|
|
660
|
+
entries: object[],
|
|
661
|
+
): Promise<jest.Mock> => {
|
|
662
|
+
const transcriptPath = path.join(rootDirectory, 'session.jsonl');
|
|
663
|
+
fs.writeFileSync(
|
|
664
|
+
transcriptPath,
|
|
665
|
+
entries.map((entry) => JSON.stringify(entry)).join('\n'),
|
|
666
|
+
'utf8',
|
|
667
|
+
);
|
|
668
|
+
const sendSelfCheckNotification = jest.fn().mockResolvedValue(undefined);
|
|
669
|
+
const useCase = new NotifySilentLiveSessionsUseCase(
|
|
670
|
+
{
|
|
671
|
+
getSnapshot: jest.fn().mockResolvedValue({
|
|
672
|
+
sessions: [{ sessionName: monitoredSessionName, panePids: [100] }],
|
|
673
|
+
processes: [
|
|
674
|
+
{
|
|
675
|
+
pid: 101,
|
|
676
|
+
ppid: 100,
|
|
677
|
+
commandLine: `claude --name ${monitoredSessionName}`,
|
|
678
|
+
sessionId: 'session-uuid',
|
|
679
|
+
currentSessionId: 'session-uuid',
|
|
680
|
+
configDir: '/config/session',
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
}),
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
resolveTranscriptPaths: jest
|
|
687
|
+
.fn()
|
|
688
|
+
.mockReturnValue(new Map([[monitoredSessionName, transcriptPath]])),
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
listSessionOutputActivities: jest.fn().mockResolvedValue([
|
|
692
|
+
{
|
|
693
|
+
sessionName: monitoredSessionName,
|
|
694
|
+
lastOutputEpochSeconds:
|
|
695
|
+
nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
|
|
696
|
+
},
|
|
697
|
+
]),
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
listSubAgentActivitiesBySessionName: jest
|
|
701
|
+
.fn()
|
|
702
|
+
.mockResolvedValue(new Map()),
|
|
703
|
+
},
|
|
704
|
+
new TranscriptOwnerCallStatusProvider('<call-to-user>'),
|
|
705
|
+
{ sendSelfCheckNotification },
|
|
706
|
+
{
|
|
707
|
+
loadRecentCandidateSessionNames: jest
|
|
708
|
+
.fn()
|
|
709
|
+
.mockResolvedValue(new EveryNameRecentSet()),
|
|
710
|
+
saveCandidateSessionNames: jest.fn().mockResolvedValue(undefined),
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
loadRecentNotifiedSessionNames: jest
|
|
714
|
+
.fn()
|
|
715
|
+
.mockResolvedValue(new Set<string>()),
|
|
716
|
+
saveNotifiedSessionNames: jest.fn().mockResolvedValue(undefined),
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
composeMainStalledSection: jest.fn().mockReturnValue('MAIN_STALLED'),
|
|
720
|
+
composeMainStalledWithStaleOwnerCallSection: jest
|
|
721
|
+
.fn()
|
|
722
|
+
.mockReturnValue('MAIN_STALLED_STALE'),
|
|
723
|
+
composeSubAgentSection: jest.fn().mockReturnValue('SUBAGENT'),
|
|
724
|
+
},
|
|
725
|
+
{ sleep: jest.fn().mockResolvedValue(undefined) },
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
await useCase.run({
|
|
729
|
+
mainSilentThresholdSeconds: DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
|
|
730
|
+
unansweredOwnerCallGraceSeconds:
|
|
731
|
+
DEFAULT_UNANSWERED_OWNER_CALL_GRACE_SECONDS,
|
|
732
|
+
subAgentSilentThresholdSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
|
|
733
|
+
subAgentRunningThresholdSeconds:
|
|
734
|
+
DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
|
|
735
|
+
staggerSeconds: DEFAULT_NOTIFICATION_STAGGER_SECONDS,
|
|
736
|
+
candidateDebounceRecencyWindowSeconds:
|
|
737
|
+
DEFAULT_CANDIDATE_DEBOUNCE_RECENCY_WINDOW_SECONDS,
|
|
738
|
+
activeHubTaskStatus: null,
|
|
739
|
+
hubTaskStatusCacheTtlSeconds: DEFAULT_HUB_TASK_STATUS_CACHE_TTL_SECONDS,
|
|
740
|
+
now,
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
return sendSelfCheckNotification;
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
it('suppresses the silent-session reminder for a session whose only owner call uses the candidate tag family', async () => {
|
|
747
|
+
const sendSelfCheckNotification = await runUseCaseWithTranscript([
|
|
748
|
+
assistantEntryWith(
|
|
749
|
+
'<call-to-user-pending>Please decide.</call-to-user-pending>',
|
|
750
|
+
),
|
|
751
|
+
]);
|
|
752
|
+
|
|
753
|
+
expect(sendSelfCheckNotification).not.toHaveBeenCalled();
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it('suppresses the silent-session reminder for a session whose only owner call uses the legacy literal tag family', async () => {
|
|
757
|
+
const sendSelfCheckNotification = await runUseCaseWithTranscript([
|
|
758
|
+
assistantEntryWith('<call-to-user>Please decide.</call-to-user>'),
|
|
759
|
+
]);
|
|
760
|
+
|
|
761
|
+
expect(sendSelfCheckNotification).not.toHaveBeenCalled();
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('still sends the silent-session reminder for a silent session that raised no owner call', async () => {
|
|
765
|
+
const sendSelfCheckNotification = await runUseCaseWithTranscript([
|
|
766
|
+
assistantEntryWith('progress update'),
|
|
767
|
+
]);
|
|
768
|
+
|
|
769
|
+
expect(sendSelfCheckNotification).toHaveBeenCalledWith(
|
|
770
|
+
monitoredSessionName,
|
|
771
|
+
'MAIN_STALLED',
|
|
772
|
+
);
|
|
773
|
+
});
|
|
774
|
+
});
|
|
@@ -88,8 +88,27 @@ const hasOwnerTextReply = (content: unknown): boolean => {
|
|
|
88
88
|
return !extractText(content).includes(SILENT_SESSION_REMINDER_SENTINEL);
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
+
const OWNER_CALL_TAG_TERMINATOR = '>';
|
|
92
|
+
const OWNER_CALL_CANDIDATE_TAG_INFIX = '-pending';
|
|
93
|
+
const OWNER_CALL_CANDIDATE_TAG_SUFFIX = `${OWNER_CALL_CANDIDATE_TAG_INFIX}${OWNER_CALL_TAG_TERMINATOR}`;
|
|
94
|
+
|
|
95
|
+
export const ownerCallMarkerFamilyResolve = (marker: string): string[] =>
|
|
96
|
+
marker.endsWith(OWNER_CALL_TAG_TERMINATOR)
|
|
97
|
+
? [
|
|
98
|
+
marker,
|
|
99
|
+
`${marker.slice(0, -OWNER_CALL_TAG_TERMINATOR.length)}${OWNER_CALL_CANDIDATE_TAG_SUFFIX}`,
|
|
100
|
+
]
|
|
101
|
+
: [marker];
|
|
102
|
+
|
|
91
103
|
export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvider {
|
|
92
|
-
|
|
104
|
+
private readonly ownerCallMarkerFamily: string[];
|
|
105
|
+
|
|
106
|
+
constructor(ownerCallMarker: string | null) {
|
|
107
|
+
this.ownerCallMarkerFamily =
|
|
108
|
+
ownerCallMarker === null || ownerCallMarker.length === 0
|
|
109
|
+
? []
|
|
110
|
+
: ownerCallMarkerFamilyResolve(ownerCallMarker);
|
|
111
|
+
}
|
|
93
112
|
|
|
94
113
|
listUnansweredOwnerCallEpochSecondsBySessionName = async (
|
|
95
114
|
transcriptPathBySessionName: Map<string, string>,
|
|
@@ -98,14 +117,13 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
|
|
|
98
117
|
string,
|
|
99
118
|
number
|
|
100
119
|
>();
|
|
101
|
-
if (this.
|
|
120
|
+
if (this.ownerCallMarkerFamily.length === 0) {
|
|
102
121
|
return unansweredOwnerCallEpochSecondsBySessionName;
|
|
103
122
|
}
|
|
104
|
-
const marker = this.ownerCallMarker;
|
|
105
123
|
for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
|
|
106
124
|
const unansweredOwnerCallEpochMs = this.findUnansweredOwnerCallEpochMs(
|
|
107
125
|
transcriptPath,
|
|
108
|
-
|
|
126
|
+
this.ownerCallMarkerFamily,
|
|
109
127
|
);
|
|
110
128
|
if (unansweredOwnerCallEpochMs !== null) {
|
|
111
129
|
unansweredOwnerCallEpochSecondsBySessionName.set(
|
|
@@ -119,7 +137,7 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
|
|
|
119
137
|
|
|
120
138
|
private findUnansweredOwnerCallEpochMs = (
|
|
121
139
|
transcriptPath: string,
|
|
122
|
-
|
|
140
|
+
markerFamily: string[],
|
|
123
141
|
): number | null => {
|
|
124
142
|
let content: string;
|
|
125
143
|
try {
|
|
@@ -150,11 +168,11 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
|
|
|
150
168
|
const type = readString(parsed, 'type');
|
|
151
169
|
const message = parsed.message;
|
|
152
170
|
const messageContent = isRecord(message) ? message.content : null;
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
171
|
+
if (type === 'assistant') {
|
|
172
|
+
const assistantText = extractText(messageContent);
|
|
173
|
+
if (markerFamily.some((marker) => assistantText.includes(marker))) {
|
|
174
|
+
lastOwnerCallEpochMs = epochMs;
|
|
175
|
+
}
|
|
158
176
|
}
|
|
159
177
|
if (
|
|
160
178
|
type === 'user' &&
|
|
@@ -3002,6 +3002,37 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
3002
3002
|
),
|
|
3003
3003
|
);
|
|
3004
3004
|
});
|
|
3005
|
+
|
|
3006
|
+
it('reports isCiStateSuccess true when an older check-run record with the same name has failure but the newer record has success', async () => {
|
|
3007
|
+
mockFetchRoutes({
|
|
3008
|
+
timeline: () => buildSlimTimelineResponse(),
|
|
3009
|
+
slimPullRequest: () =>
|
|
3010
|
+
buildSlimPullRequestResponse({
|
|
3011
|
+
url: 'https://github.com/HiromiShikata/test-repository/pull/11148',
|
|
3012
|
+
reviewThreads: {
|
|
3013
|
+
pageInfo: { endCursor: null, hasNextPage: false },
|
|
3014
|
+
nodes: [],
|
|
3015
|
+
},
|
|
3016
|
+
}),
|
|
3017
|
+
branchRules: () => [],
|
|
3018
|
+
checkRuns: () => ({
|
|
3019
|
+
total_count: 2,
|
|
3020
|
+
check_runs: [
|
|
3021
|
+
{ id: 100, name: 'ci', conclusion: 'failure' },
|
|
3022
|
+
{ id: 200, name: 'ci', conclusion: 'success' },
|
|
3023
|
+
],
|
|
3024
|
+
}),
|
|
3025
|
+
});
|
|
3026
|
+
|
|
3027
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
3028
|
+
const result = await repository.findRelatedOpenPRs(
|
|
3029
|
+
'https://github.com/HiromiShikata/test-repository/issues/11194',
|
|
3030
|
+
);
|
|
3031
|
+
|
|
3032
|
+
expect(result).toHaveLength(1);
|
|
3033
|
+
expect(result[0].isCiStateSuccess).toBe(true);
|
|
3034
|
+
expect(result[0].isPassedAllCiJob).toBe(true);
|
|
3035
|
+
});
|
|
3005
3036
|
});
|
|
3006
3037
|
|
|
3007
3038
|
describe('findRelatedOpenPRs cross-repo PR', () => {
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
formatProjectHeaderLine,
|
|
7
7
|
formatProjectRowLine,
|
|
8
8
|
formatResetCountdown,
|
|
9
|
+
formatSevenDayWindowAggregateLine,
|
|
9
10
|
formatTokenRowLine,
|
|
10
11
|
roundHalfToEven,
|
|
11
12
|
} from './ComposeDashboardUseCase';
|
|
@@ -485,3 +486,166 @@ describe('ComposeDashboardUseCase', () => {
|
|
|
485
486
|
}
|
|
486
487
|
});
|
|
487
488
|
});
|
|
489
|
+
|
|
490
|
+
describe('formatSevenDayWindowAggregateLine', () => {
|
|
491
|
+
const columnEnd = (line: string, field: string): number =>
|
|
492
|
+
codePointLength(line.slice(0, line.indexOf(field) + field.length));
|
|
493
|
+
|
|
494
|
+
it('renders only the remaining percentage, without a count when every token is included', () => {
|
|
495
|
+
expect(
|
|
496
|
+
formatSevenDayWindowAggregateLine({
|
|
497
|
+
usedPercent: 63,
|
|
498
|
+
includedTokenCount: 10,
|
|
499
|
+
totalTokenCount: 10,
|
|
500
|
+
}),
|
|
501
|
+
).toBe('7d 37%');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('aligns the remaining percentage under the seven day column of the token rows', () => {
|
|
505
|
+
const tokenRow = formatTokenRowLine(
|
|
506
|
+
tokenStatus({
|
|
507
|
+
name: 'dev4',
|
|
508
|
+
color: 'K',
|
|
509
|
+
fiveHourUtilizationPercent: 0,
|
|
510
|
+
fiveHourResetSeconds: 60,
|
|
511
|
+
sevenDayUtilizationPercent: 100,
|
|
512
|
+
sevenDayResetSeconds: 96060,
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
515
|
+
const aggregateLine = formatSevenDayWindowAggregateLine({
|
|
516
|
+
usedPercent: 63.36,
|
|
517
|
+
includedTokenCount: 11,
|
|
518
|
+
totalTokenCount: 11,
|
|
519
|
+
});
|
|
520
|
+
expect(tokenRow).toBe('⚪dev4 0% 0d00h01 100% 1d02h41 0 0');
|
|
521
|
+
expect(aggregateLine).toBe('7d 37%');
|
|
522
|
+
expect(columnEnd(aggregateLine ?? '', '37%')).toBe(
|
|
523
|
+
columnEnd(tokenRow, '100%'),
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('keeps the alignment when the remaining percentage needs fewer digits', () => {
|
|
528
|
+
const wide = formatSevenDayWindowAggregateLine({
|
|
529
|
+
usedPercent: 0,
|
|
530
|
+
includedTokenCount: 1,
|
|
531
|
+
totalTokenCount: 1,
|
|
532
|
+
});
|
|
533
|
+
const narrow = formatSevenDayWindowAggregateLine({
|
|
534
|
+
usedPercent: 95,
|
|
535
|
+
includedTokenCount: 1,
|
|
536
|
+
totalTokenCount: 1,
|
|
537
|
+
});
|
|
538
|
+
expect(wide).toBe('7d 100%');
|
|
539
|
+
expect(narrow).toBe('7d 5%');
|
|
540
|
+
expect(codePointLength(wide ?? '')).toBe(codePointLength(narrow ?? ''));
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('appends the included token count when some tokens have no value', () => {
|
|
544
|
+
expect(
|
|
545
|
+
formatSevenDayWindowAggregateLine({
|
|
546
|
+
usedPercent: 63,
|
|
547
|
+
includedTokenCount: 9,
|
|
548
|
+
totalTokenCount: 10,
|
|
549
|
+
}),
|
|
550
|
+
).toBe('7d 37% (9)');
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('rounds the remaining percentage with half-to-even before rendering', () => {
|
|
554
|
+
expect(
|
|
555
|
+
formatSevenDayWindowAggregateLine({
|
|
556
|
+
usedPercent: 62.5,
|
|
557
|
+
includedTokenCount: 2,
|
|
558
|
+
totalTokenCount: 2,
|
|
559
|
+
}),
|
|
560
|
+
).toBe('7d 38%');
|
|
561
|
+
expect(
|
|
562
|
+
formatSevenDayWindowAggregateLine({
|
|
563
|
+
usedPercent: 63.5,
|
|
564
|
+
includedTokenCount: 2,
|
|
565
|
+
totalTokenCount: 2,
|
|
566
|
+
}),
|
|
567
|
+
).toBe('7d 36%');
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('renders nothing when the aggregate is absent', () => {
|
|
571
|
+
expect(formatSevenDayWindowAggregateLine(null)).toBeNull();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('fits the width budget at the widest rendering', () => {
|
|
575
|
+
const line = formatSevenDayWindowAggregateLine({
|
|
576
|
+
usedPercent: 0,
|
|
577
|
+
includedTokenCount: 999,
|
|
578
|
+
totalTokenCount: 1000,
|
|
579
|
+
});
|
|
580
|
+
expect(line).toBe('7d 100% (999)');
|
|
581
|
+
expect(codePointLength(line ?? '')).toBeLessThanOrEqual(
|
|
582
|
+
PROJECT_ROW_WIDTH_BUDGET,
|
|
583
|
+
);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
describe('ComposeDashboardUseCase seven day window aggregate line', () => {
|
|
588
|
+
const aggregateInput = (
|
|
589
|
+
sevenDayWindowAggregate: ComposeDashboardInput['sevenDayWindowAggregate'],
|
|
590
|
+
): ComposeDashboardInput => ({
|
|
591
|
+
machineStatus: null,
|
|
592
|
+
projects: [{ code: 'um', row: projectRow({ unread: 1 }) }],
|
|
593
|
+
tokens: [
|
|
594
|
+
tokenStatus({ name: 'alice', sevenDayUtilizationPercent: 60 }),
|
|
595
|
+
tokenStatus({ name: 'bob', sevenDayUtilizationPercent: 66 }),
|
|
596
|
+
],
|
|
597
|
+
sevenDayWindowAggregate,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const unwrappedLines = (output: string): string[] =>
|
|
601
|
+
output
|
|
602
|
+
.split('\n')
|
|
603
|
+
.filter((line) => line.length > 0)
|
|
604
|
+
.map((line) =>
|
|
605
|
+
line
|
|
606
|
+
.replace(/^<tt>/, '')
|
|
607
|
+
.replace(/<\/tt><br>$/, '')
|
|
608
|
+
.replace(/ /g, ' '),
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
it('places the aggregate line immediately above the token rows', () => {
|
|
612
|
+
const lines = unwrappedLines(
|
|
613
|
+
new ComposeDashboardUseCase().run(
|
|
614
|
+
aggregateInput({
|
|
615
|
+
usedPercent: 63,
|
|
616
|
+
includedTokenCount: 2,
|
|
617
|
+
totalTokenCount: 2,
|
|
618
|
+
}),
|
|
619
|
+
),
|
|
620
|
+
);
|
|
621
|
+
const aggregateIndex = lines.indexOf('7d 37%');
|
|
622
|
+
expect(aggregateIndex).toBeGreaterThan(-1);
|
|
623
|
+
expect(lines[aggregateIndex - 1]).toBe(
|
|
624
|
+
formatProjectRowLine({
|
|
625
|
+
code: 'um',
|
|
626
|
+
row: projectRow({ unread: 1 }),
|
|
627
|
+
}),
|
|
628
|
+
);
|
|
629
|
+
expect(lines[aggregateIndex + 1]).toContain('alice');
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('keeps the blank separator line when the aggregate is absent', () => {
|
|
633
|
+
const lines = unwrappedLines(
|
|
634
|
+
new ComposeDashboardUseCase().run(aggregateInput(null)),
|
|
635
|
+
);
|
|
636
|
+
expect(lines.some((line) => line.startsWith('7d '))).toBe(false);
|
|
637
|
+
expect(lines).toContain('');
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('keeps the blank separator line when the aggregate field is missing entirely', () => {
|
|
641
|
+
const lines = unwrappedLines(
|
|
642
|
+
new ComposeDashboardUseCase().run({
|
|
643
|
+
machineStatus: null,
|
|
644
|
+
projects: [],
|
|
645
|
+
tokens: [tokenStatus({ name: 'alice' })],
|
|
646
|
+
}),
|
|
647
|
+
);
|
|
648
|
+
expect(lines.some((line) => line.startsWith('7d '))).toBe(false);
|
|
649
|
+
expect(lines).toContain('');
|
|
650
|
+
});
|
|
651
|
+
});
|