@opencode-trace/plugin 0.0.4 → 0.0.5

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.
@@ -1,7 +1,8 @@
1
1
  import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
- import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
2
+ import { mkdtempSync, rmSync, existsSync, readFileSync, } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { logger } from "@opencode-trace/core";
5
6
  import entrypoint, { _resetForTesting } from "./trace.js";
6
7
  vi.mock("node:os", async (importOriginal) => {
7
8
  const original = await importOriginal();
@@ -26,13 +27,12 @@ async function waitForFile(filePath, timeoutMs = 5000) {
26
27
  return;
27
28
  }
28
29
  }
29
- catch {
30
- }
30
+ catch { }
31
31
  }
32
32
  if (Date.now() - startTime > timeoutMs) {
33
33
  throw new Error(`Timeout waiting for valid file ${filePath} after ${timeoutMs}ms`);
34
34
  }
35
- await new Promise(r => setTimeout(r, 10));
35
+ await new Promise((r) => setTimeout(r, 10));
36
36
  }
37
37
  }
38
38
  let testDir;
@@ -100,8 +100,8 @@ describe("Plugin - event hook 处理 session.created", () => {
100
100
  const traceDir = join(testDir, ".opencode-trace");
101
101
  const sessionDir = join(traceDir, sessionId);
102
102
  expect(existsSync(sessionDir)).toBe(true);
103
- const dbPath = join(traceDir, "state.db");
104
- expect(existsSync(dbPath)).toBe(true);
103
+ const configPath = join(traceDir, "config.json");
104
+ expect(existsSync(configPath)).toBe(true);
105
105
  const metaPath = join(sessionDir, "metadata.json");
106
106
  expect(existsSync(metaPath)).toBe(true);
107
107
  const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
@@ -162,9 +162,13 @@ describe("tracedFetch stream integration", () => {
162
162
  const mockFetch = vi.fn();
163
163
  const originalFetch = globalThis.fetch;
164
164
  globalThis.fetch = mockFetch;
165
- const chunks = ["data: {\"content\": \"Hello\"}\n", "data: {\"content\": \"World\"}\n", "data: [DONE]\n"];
165
+ const chunks = [
166
+ 'data: {"content": "Hello"}\n',
167
+ 'data: {"content": "World"}\n',
168
+ "data: [DONE]\n",
169
+ ];
166
170
  const encoder = new TextEncoder();
167
- const streamChunks = chunks.map(c => encoder.encode(c));
171
+ const streamChunks = chunks.map((c) => encoder.encode(c));
168
172
  const mockStream = new ReadableStream({
169
173
  start(controller) {
170
174
  for (const chunk of streamChunks) {
@@ -208,7 +212,10 @@ describe("tracedFetch stream integration", () => {
208
212
  "content-type": "application/json",
209
213
  "x-opencode-session": sessionId,
210
214
  },
211
- body: JSON.stringify({ stream: true, messages: [{ role: "user", content: "test" }] }),
215
+ body: JSON.stringify({
216
+ stream: true,
217
+ messages: [{ role: "user", content: "test" }],
218
+ }),
212
219
  });
213
220
  const response = await globalThis.fetch(streamRequest);
214
221
  expect(response.__latencyMeta).toBeDefined();
@@ -278,7 +285,10 @@ describe("tracedFetch stream integration", () => {
278
285
  "content-type": "application/json",
279
286
  "x-opencode-session": sessionId,
280
287
  },
281
- body: JSON.stringify({ stream: false, messages: [{ role: "user", content: "test" }] }),
288
+ body: JSON.stringify({
289
+ stream: false,
290
+ messages: [{ role: "user", content: "test" }],
291
+ }),
282
292
  });
283
293
  const response = await globalThis.fetch(request);
284
294
  expect(response.__latencyMeta).toBeUndefined();
@@ -324,8 +334,8 @@ describe("Plugin - tool.execute.after hook 处理 Task 工具", () => {
324
334
  metadata: { session_id: subSessionId },
325
335
  });
326
336
  const traceDir = join(testDir, ".opencode-trace");
327
- const dbPath = join(traceDir, "state.db");
328
- expect(existsSync(dbPath)).toBe(true);
337
+ const configPath = join(traceDir, "config.json");
338
+ expect(existsSync(configPath)).toBe(true);
329
339
  });
330
340
  test("tool.execute.after hook 对非 Task 工具不记录 sub session", async () => {
331
341
  const hooks = await entrypoint.server({
@@ -364,12 +374,12 @@ describe("Plugin - tool.execute.after hook 处理 Task 工具", () => {
364
374
  metadata: {},
365
375
  });
366
376
  const traceDir = join(testDir, ".opencode-trace");
367
- const dbPath = join(traceDir, "state.db");
368
- expect(existsSync(dbPath)).toBe(true);
377
+ const configPath = join(traceDir, "config.json");
378
+ expect(existsSync(configPath)).toBe(true);
369
379
  });
370
380
  });
371
381
  describe("Plugin - global/local mode", () => {
372
- test("trace_use_global tool switches to global mode", async () => {
382
+ test("trace_on tool enables session scope", async () => {
373
383
  const hooks = await entrypoint.server({
374
384
  client: {},
375
385
  project: {},
@@ -379,7 +389,7 @@ describe("Plugin - global/local mode", () => {
379
389
  serverUrl: new URL("http://localhost"),
380
390
  $: {},
381
391
  });
382
- const sessionId = "test-session-global";
392
+ const sessionId = "test-session-on";
383
393
  await hooks.event({
384
394
  event: {
385
395
  type: "session.created",
@@ -395,8 +405,10 @@ describe("Plugin - global/local mode", () => {
395
405
  },
396
406
  },
397
407
  });
398
- const result = await hooks.tool.trace_use_global.execute({}, { sessionID: sessionId });
399
- expect(result).toContain("global mode");
408
+ const result = await hooks.tool.trace_on.execute({}, {
409
+ sessionID: sessionId,
410
+ });
411
+ expect(result).toContain("Trace enabled for session");
400
412
  const globalDir = join(testDir, ".opencode-trace");
401
413
  const sessionDir = join(globalDir, sessionId);
402
414
  await hooks.event({
@@ -414,10 +426,10 @@ describe("Plugin - global/local mode", () => {
414
426
  },
415
427
  },
416
428
  });
417
- await new Promise(r => setTimeout(r, 100));
429
+ await new Promise((r) => setTimeout(r, 100));
418
430
  expect(existsSync(sessionDir)).toBe(true);
419
431
  });
420
- test("trace_use_local tool switches to local mode", async () => {
432
+ test("trace_off tool disables session scope", async () => {
421
433
  const hooks = await entrypoint.server({
422
434
  client: {},
423
435
  project: {},
@@ -427,7 +439,7 @@ describe("Plugin - global/local mode", () => {
427
439
  serverUrl: new URL("http://localhost"),
428
440
  $: {},
429
441
  });
430
- const sessionId = "test-session-local";
442
+ const sessionId = "test-session-off";
431
443
  await hooks.event({
432
444
  event: {
433
445
  type: "session.created",
@@ -443,29 +455,12 @@ describe("Plugin - global/local mode", () => {
443
455
  },
444
456
  },
445
457
  });
446
- const result = await hooks.tool.trace_use_local.execute({}, { sessionID: sessionId });
447
- expect(result).toContain("local mode");
448
- const localDir = join(testDir, ".opencode-trace");
449
- const sessionDir = join(localDir, sessionId);
450
- await hooks.event({
451
- event: {
452
- type: "session.updated",
453
- properties: {
454
- info: {
455
- id: sessionId,
456
- projectID: "test-project",
457
- directory: testDir,
458
- title: "Test Session Updated",
459
- version: "1.0",
460
- time: { created: Date.now(), updated: Date.now() },
461
- },
462
- },
463
- },
458
+ const result = await hooks.tool.trace_off.execute({}, {
459
+ sessionID: sessionId,
464
460
  });
465
- await new Promise(r => setTimeout(r, 100));
466
- expect(existsSync(sessionDir)).toBe(true);
461
+ expect(result).toContain("Trace disabled for session");
467
462
  });
468
- test("trace_storage_status shows current mode", async () => {
463
+ test("trace_status tool shows current status", async () => {
469
464
  const hooks = await entrypoint.server({
470
465
  client: {},
471
466
  project: {},
@@ -491,11 +486,599 @@ describe("Plugin - global/local mode", () => {
491
486
  },
492
487
  },
493
488
  });
494
- const result = await hooks.tool.trace_storage_status.execute({}, { sessionID: sessionId });
495
- const parsed = JSON.parse(result);
496
- expect(parsed.mode).toBeDefined();
497
- expect(parsed.globalDir).toBeDefined();
498
- expect(parsed.localDir).toBeDefined();
489
+ const result = await hooks.tool.trace_status.execute({}, {
490
+ sessionID: sessionId,
491
+ });
492
+ expect(result).toContain("Trace Status");
493
+ expect(result).toContain("Global");
494
+ expect(result).toContain("Local");
495
+ expect(result).toContain("Session");
496
+ expect(result).toContain("Storage");
497
+ });
498
+ });
499
+ async function setupPluginWithMockClient(testDir) {
500
+ _resetForTesting();
501
+ const mockPrompt = vi.fn().mockResolvedValue({});
502
+ const hooks = await entrypoint.server({
503
+ client: { session: { prompt: mockPrompt } },
504
+ project: {},
505
+ directory: testDir,
506
+ worktree: testDir,
507
+ experimental_workspace: { register: vi.fn() },
508
+ serverUrl: new URL("http://localhost"),
509
+ $: {},
510
+ });
511
+ return { hooks, mockPrompt };
512
+ }
513
+ async function runTraceCommand(hooks, mockPrompt, args, sessionId = "test-slash-session") {
514
+ const output = { parts: [{ type: "text", text: "original" }] };
515
+ let error = null;
516
+ try {
517
+ await hooks["command.execute.before"]({
518
+ command: "trace",
519
+ sessionID: sessionId,
520
+ arguments: args,
521
+ }, output);
522
+ }
523
+ catch (err) {
524
+ error = err;
525
+ }
526
+ expect(error).toBeTruthy();
527
+ expect(error.message).toBe("__TRACE_HANDLED__");
528
+ expect(output.parts.length).toBe(0);
529
+ if (mockPrompt.mock.calls.length > 0) {
530
+ const call = mockPrompt.mock.calls[0][0];
531
+ return { text: call.body.parts[0].text, outputParts: output.parts };
532
+ }
533
+ return { text: null, outputParts: output.parts };
534
+ }
535
+ async function createSessionViaEvent(hooks, sessionId, testDir, title = "Slash Test Session", parentID) {
536
+ await hooks.event({
537
+ event: {
538
+ type: "session.created",
539
+ properties: {
540
+ info: {
541
+ id: sessionId,
542
+ projectID: "test-project",
543
+ directory: testDir,
544
+ title,
545
+ parentID,
546
+ version: "1.0",
547
+ time: { created: Date.now(), updated: Date.now() },
548
+ },
549
+ },
550
+ },
551
+ });
552
+ }
553
+ function readConfig(testDir) {
554
+ const configPath = join(testDir, ".opencode-trace", "config.json");
555
+ return JSON.parse(readFileSync(configPath, "utf-8"));
556
+ }
557
+ function readSessionMetadata(testDir, sessionId) {
558
+ const metaPath = join(testDir, ".opencode-trace", sessionId, "metadata.json");
559
+ if (!existsSync(metaPath))
560
+ return null;
561
+ return JSON.parse(readFileSync(metaPath, "utf-8"));
562
+ }
563
+ describe("Plugin - /trace on (slash command)", () => {
564
+ test("/trace on with no flags enables global scope by default", async () => {
565
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
566
+ const sessionId = "slash-on-default";
567
+ await createSessionViaEvent(hooks, sessionId, testDir);
568
+ const { text } = await runTraceCommand(hooks, mockPrompt, "on", sessionId);
569
+ expect(text).toContain("Trace enabled");
570
+ expect(text).toContain("global");
571
+ const config = readConfig(testDir);
572
+ expect(config.global_trace_enabled).toBe(true);
573
+ });
574
+ test("/trace on -g explicitly enables global scope", async () => {
575
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
576
+ const sessionId = "slash-on-g";
577
+ await createSessionViaEvent(hooks, sessionId, testDir);
578
+ const { text } = await runTraceCommand(hooks, mockPrompt, "on -g", sessionId);
579
+ expect(text).toContain("Trace enabled");
580
+ expect(text).toContain("global");
581
+ const config = readConfig(testDir);
582
+ expect(config.global_trace_enabled).toBe(true);
583
+ });
584
+ test("/trace on --global (long form) enables global scope", async () => {
585
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
586
+ const sessionId = "slash-on-long-global";
587
+ await createSessionViaEvent(hooks, sessionId, testDir);
588
+ const { text } = await runTraceCommand(hooks, mockPrompt, "on --global", sessionId);
589
+ expect(text).toContain("Trace enabled");
590
+ expect(text).toContain("global");
591
+ const config = readConfig(testDir);
592
+ expect(config.global_trace_enabled).toBe(true);
593
+ });
594
+ test("/trace on -l enables local scope", async () => {
595
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
596
+ const sessionId = "slash-on-l";
597
+ await createSessionViaEvent(hooks, sessionId, testDir);
598
+ const { text } = await runTraceCommand(hooks, mockPrompt, "on -l", sessionId);
599
+ expect(text).toContain("Trace enabled");
600
+ expect(text).toContain("local");
601
+ const config = readConfig(testDir);
602
+ expect(config.global_trace_enabled).toBe(true);
603
+ });
604
+ test("/trace on -s enables session scope", async () => {
605
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
606
+ const sessionId = "slash-on-s";
607
+ await createSessionViaEvent(hooks, sessionId, testDir);
608
+ const { text } = await runTraceCommand(hooks, mockPrompt, "on -s", sessionId);
609
+ expect(text).toContain("Trace enabled");
610
+ expect(text).toContain("session");
611
+ const meta = readSessionMetadata(testDir, sessionId);
612
+ expect(meta).toBeTruthy();
613
+ expect(meta.trace_enabled).toBe(true);
614
+ });
615
+ test("/trace on -d local sets storage preference to local", async () => {
616
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
617
+ const sessionId = "slash-on-d-local";
618
+ await createSessionViaEvent(hooks, sessionId, testDir);
619
+ const { text } = await runTraceCommand(hooks, mockPrompt, "on -d local", sessionId);
620
+ expect(text).toContain("Trace enabled");
621
+ expect(text).toContain("storage: local");
622
+ const config = readConfig(testDir);
623
+ expect(config.storage_preference).toBe("local");
624
+ expect(config.global_trace_enabled).toBe(true);
625
+ });
626
+ test("/trace on -d global sets storage preference to global", async () => {
627
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
628
+ const sessionId = "slash-on-d-global";
629
+ await createSessionViaEvent(hooks, sessionId, testDir);
630
+ const { text } = await runTraceCommand(hooks, mockPrompt, "on -d global", sessionId);
631
+ expect(text).toContain("Trace enabled");
632
+ const config = readConfig(testDir);
633
+ expect(config.storage_preference).toBe("global");
634
+ });
635
+ test("/trace on -g -l enables both global and local scopes", async () => {
636
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
637
+ const sessionId = "slash-on-gl";
638
+ await createSessionViaEvent(hooks, sessionId, testDir);
639
+ const { text } = await runTraceCommand(hooks, mockPrompt, "on -g -l", sessionId);
640
+ expect(text).toContain("Trace enabled");
641
+ expect(text).toContain("global");
642
+ expect(text).toContain("local");
643
+ const config = readConfig(testDir);
644
+ expect(config.global_trace_enabled).toBe(true);
645
+ });
646
+ test("/trace on -g -l -s enables all three scopes", async () => {
647
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
648
+ const sessionId = "slash-on-gls";
649
+ await createSessionViaEvent(hooks, sessionId, testDir);
650
+ const { text } = await runTraceCommand(hooks, mockPrompt, "on -g -l -s", sessionId);
651
+ expect(text).toContain("Trace enabled");
652
+ expect(text).toContain("global");
653
+ expect(text).toContain("local");
654
+ expect(text).toContain("session");
655
+ const meta = readSessionMetadata(testDir, sessionId);
656
+ expect(meta.trace_enabled).toBe(true);
657
+ });
658
+ test("/trace on -s -d local enables session and sets local storage", async () => {
659
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
660
+ const sessionId = "slash-on-s-d-local";
661
+ await createSessionViaEvent(hooks, sessionId, testDir);
662
+ const { text } = await runTraceCommand(hooks, mockPrompt, "on -s -d local", sessionId);
663
+ expect(text).toContain("Trace enabled");
664
+ expect(text).toContain("session");
665
+ expect(text).toContain("storage: local");
666
+ const meta = readSessionMetadata(testDir, sessionId);
667
+ expect(meta.trace_enabled).toBe(true);
668
+ expect(meta.storage_preference).toBe("local");
669
+ });
670
+ test("/trace enable (alias) works the same as /trace on", async () => {
671
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
672
+ const sessionId = "slash-enable-alias";
673
+ await createSessionViaEvent(hooks, sessionId, testDir);
674
+ const { text } = await runTraceCommand(hooks, mockPrompt, "enable", sessionId);
675
+ expect(text).toContain("Trace enabled");
676
+ expect(text).toContain("global");
677
+ });
678
+ });
679
+ describe("Plugin - /trace off (slash command)", () => {
680
+ test("/trace off with no flags disables global scope by default", async () => {
681
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
682
+ const sessionId = "slash-off-default";
683
+ await createSessionViaEvent(hooks, sessionId, testDir);
684
+ const { text } = await runTraceCommand(hooks, mockPrompt, "off", sessionId);
685
+ expect(text).toContain("Trace disabled");
686
+ expect(text).toContain("global");
687
+ const config = readConfig(testDir);
688
+ expect(config.global_trace_enabled).toBe(false);
689
+ });
690
+ test("/trace off -g explicitly disables global scope", async () => {
691
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
692
+ const sessionId = "slash-off-g";
693
+ await createSessionViaEvent(hooks, sessionId, testDir);
694
+ const { text } = await runTraceCommand(hooks, mockPrompt, "off -g", sessionId);
695
+ expect(text).toContain("Trace disabled");
696
+ expect(text).toContain("global");
697
+ const config = readConfig(testDir);
698
+ expect(config.global_trace_enabled).toBe(false);
699
+ });
700
+ test("/trace off -l disables local scope", async () => {
701
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
702
+ const sessionId = "slash-off-l";
703
+ await createSessionViaEvent(hooks, sessionId, testDir);
704
+ const { text } = await runTraceCommand(hooks, mockPrompt, "off -l", sessionId);
705
+ expect(text).toContain("Trace disabled");
706
+ expect(text).toContain("local");
707
+ const config = readConfig(testDir);
708
+ expect(config.global_trace_enabled).toBe(false);
709
+ });
710
+ test("/trace off -s disables session scope", async () => {
711
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
712
+ const sessionId = "slash-off-s";
713
+ await createSessionViaEvent(hooks, sessionId, testDir);
714
+ const { text } = await runTraceCommand(hooks, mockPrompt, "off -s", sessionId);
715
+ expect(text).toContain("Trace disabled");
716
+ expect(text).toContain("session");
717
+ const meta = readSessionMetadata(testDir, sessionId);
718
+ expect(meta.trace_enabled).toBe(false);
719
+ });
720
+ test("/trace off -g -l -s disables all three scopes", async () => {
721
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
722
+ const sessionId = "slash-off-all";
723
+ await createSessionViaEvent(hooks, sessionId, testDir);
724
+ const { text } = await runTraceCommand(hooks, mockPrompt, "off -g -l -s", sessionId);
725
+ expect(text).toContain("Trace disabled");
726
+ expect(text).toContain("global");
727
+ expect(text).toContain("local");
728
+ expect(text).toContain("session");
729
+ const config = readConfig(testDir);
730
+ expect(config.global_trace_enabled).toBe(false);
731
+ const meta = readSessionMetadata(testDir, sessionId);
732
+ expect(meta.trace_enabled).toBe(false);
733
+ });
734
+ test("/trace disable (alias) works the same as /trace off", async () => {
735
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
736
+ const sessionId = "slash-disable-alias";
737
+ await createSessionViaEvent(hooks, sessionId, testDir);
738
+ const { text } = await runTraceCommand(hooks, mockPrompt, "disable", sessionId);
739
+ expect(text).toContain("Trace disabled");
740
+ expect(text).toContain("global");
741
+ });
742
+ });
743
+ describe("Plugin - /trace status (slash command)", () => {
744
+ test("/trace status with no flags shows full status", async () => {
745
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
746
+ const sessionId = "slash-status-default";
747
+ await createSessionViaEvent(hooks, sessionId, testDir);
748
+ const { text } = await runTraceCommand(hooks, mockPrompt, "status", sessionId);
749
+ expect(text).toContain("Trace Status");
750
+ expect(text).toContain("Global");
751
+ expect(text).toContain("Local");
752
+ expect(text).toContain("Session");
753
+ expect(text).toContain("Storage");
754
+ expect(text).toContain("Effective");
755
+ });
756
+ test("/trace status -g shows status with global flag", async () => {
757
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
758
+ const sessionId = "slash-status-g";
759
+ await createSessionViaEvent(hooks, sessionId, testDir);
760
+ const { text } = await runTraceCommand(hooks, mockPrompt, "status -g", sessionId);
761
+ expect(text).toContain("Trace Status");
762
+ });
763
+ test("/trace status -l shows status with local flag", async () => {
764
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
765
+ const sessionId = "slash-status-l";
766
+ await createSessionViaEvent(hooks, sessionId, testDir);
767
+ const { text } = await runTraceCommand(hooks, mockPrompt, "status -l", sessionId);
768
+ expect(text).toContain("Trace Status");
769
+ });
770
+ test("/trace status -s shows status with session flag", async () => {
771
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
772
+ const sessionId = "slash-status-s";
773
+ await createSessionViaEvent(hooks, sessionId, testDir);
774
+ const { text } = await runTraceCommand(hooks, mockPrompt, "status -s", sessionId);
775
+ expect(text).toContain("Trace Status");
776
+ expect(text).toContain(`Session : ON`);
777
+ });
778
+ test("/trace status reflects ON state after /trace on -g", async () => {
779
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
780
+ const sessionId = "slash-status-after-on";
781
+ await createSessionViaEvent(hooks, sessionId, testDir);
782
+ await runTraceCommand(hooks, mockPrompt, "on -g", sessionId);
783
+ mockPrompt.mockClear();
784
+ const { text } = await runTraceCommand(hooks, mockPrompt, "status", sessionId);
785
+ expect(text).toContain("Trace Status");
786
+ expect(text).toContain("Global : ON");
787
+ expect(text).toContain("Effective: RECORDING");
788
+ });
789
+ });
790
+ describe("Plugin - /trace help and unknown commands", () => {
791
+ test("/trace help shows help text", async () => {
792
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
793
+ const sessionId = "slash-help";
794
+ await createSessionViaEvent(hooks, sessionId, testDir);
795
+ const { text } = await runTraceCommand(hooks, mockPrompt, "help", sessionId);
796
+ expect(text).toContain("Usage: /trace");
797
+ expect(text).toContain("Commands:");
798
+ expect(text).toContain("on");
799
+ expect(text).toContain("off");
800
+ expect(text).toContain("status");
801
+ expect(text).toContain("-g");
802
+ expect(text).toContain("-l");
803
+ expect(text).toContain("-s");
804
+ expect(text).toContain("-d");
805
+ });
806
+ test("/trace with no arguments also shows help", async () => {
807
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
808
+ const sessionId = "slash-no-args";
809
+ await createSessionViaEvent(hooks, sessionId, testDir);
810
+ const { text } = await runTraceCommand(hooks, mockPrompt, "", sessionId);
811
+ expect(text).toContain("Usage: /trace");
812
+ });
813
+ test("/trace foo (unknown subcommand) returns error message without crashing", async () => {
814
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
815
+ const sessionId = "slash-unknown";
816
+ await createSessionViaEvent(hooks, sessionId, testDir);
817
+ const { text } = await runTraceCommand(hooks, mockPrompt, "foo", sessionId);
818
+ expect(text).toContain("Unknown command: foo");
819
+ expect(text).toContain("/trace on");
820
+ expect(text).toContain("/trace off");
821
+ expect(text).toContain("/trace status");
822
+ });
823
+ test("/trace with mixed-case ON is normalized", async () => {
824
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
825
+ const sessionId = "slash-mixed-case";
826
+ await createSessionViaEvent(hooks, sessionId, testDir);
827
+ const { text } = await runTraceCommand(hooks, mockPrompt, "ON", sessionId);
828
+ expect(text).toContain("Trace enabled");
829
+ });
830
+ });
831
+ describe("Plugin - slash command guards", () => {
832
+ test("non-trace commands are ignored (no prompt sent, no throw)", async () => {
833
+ const { hooks, mockPrompt } = await setupPluginWithMockClient(testDir);
834
+ const sessionId = "slash-other-cmd";
835
+ await createSessionViaEvent(hooks, sessionId, testDir);
836
+ const output = { parts: [{ type: "text", text: "original" }] };
837
+ let error = null;
838
+ try {
839
+ await hooks["command.execute.before"]({
840
+ command: "help",
841
+ sessionID: sessionId,
842
+ arguments: "",
843
+ }, output);
844
+ }
845
+ catch (err) {
846
+ error = err;
847
+ }
848
+ expect(error).toBeNull();
849
+ expect(mockPrompt).not.toHaveBeenCalled();
850
+ expect(output.parts.length).toBe(1);
851
+ });
852
+ });
853
+ describe("Plugin - tool.execute.after parentID linking", () => {
854
+ test("tool.execute.after with task tool records sub-session under parent's subSessions", async () => {
855
+ const { hooks, mockPrompt: _mockPrompt } = await setupPluginWithMockClient(testDir);
856
+ void _mockPrompt;
857
+ const parentSessionId = "parent-link-test";
858
+ const subSessionId = "sub-link-test";
859
+ await createSessionViaEvent(hooks, parentSessionId, testDir, "Parent");
860
+ await hooks["tool.execute.after"]({
861
+ tool: "task",
862
+ sessionID: parentSessionId,
863
+ callID: "call-789",
864
+ args: { description: "do work", prompt: "do work" },
865
+ }, {
866
+ title: "Task done",
867
+ output: "completed",
868
+ metadata: { session_id: subSessionId },
869
+ });
870
+ const parentMeta = readSessionMetadata(testDir, parentSessionId);
871
+ expect(parentMeta).toBeTruthy();
872
+ expect(parentMeta.subSessions).toBeDefined();
873
+ expect(parentMeta.subSessions).toContain(subSessionId);
874
+ });
875
+ test("tool.execute.after ignores task output without session_id metadata", async () => {
876
+ const { hooks, mockPrompt: _mockPrompt } = await setupPluginWithMockClient(testDir);
877
+ void _mockPrompt;
878
+ const parentSessionId = "parent-link-no-sid";
879
+ await createSessionViaEvent(hooks, parentSessionId, testDir, "Parent");
880
+ await hooks["tool.execute.after"]({
881
+ tool: "task",
882
+ sessionID: parentSessionId,
883
+ callID: "call-no-sid",
884
+ args: { description: "test" },
885
+ }, {
886
+ title: "Task done",
887
+ output: "completed",
888
+ metadata: {},
889
+ });
890
+ const parentMeta = readSessionMetadata(testDir, parentSessionId);
891
+ if (parentMeta.subSessions) {
892
+ expect(parentMeta.subSessions).not.toContain("phantom-sub");
893
+ }
894
+ });
895
+ test("event hook with parentID records sub-session in parent metadata", async () => {
896
+ const { hooks, mockPrompt: _mockPrompt } = await setupPluginWithMockClient(testDir);
897
+ void _mockPrompt;
898
+ const parentSessionId = "parent-event-link";
899
+ const childSessionId = "child-event-link";
900
+ await createSessionViaEvent(hooks, parentSessionId, testDir, "Parent");
901
+ await createSessionViaEvent(hooks, childSessionId, testDir, "Child", parentSessionId);
902
+ const parentMeta = readSessionMetadata(testDir, parentSessionId);
903
+ expect(parentMeta).toBeTruthy();
904
+ expect(parentMeta.subSessions).toBeDefined();
905
+ expect(parentMeta.subSessions).toContain(childSessionId);
906
+ const childMeta = readSessionMetadata(testDir, childSessionId);
907
+ expect(childMeta).toBeTruthy();
908
+ expect(childMeta.parentID).toBe(parentSessionId);
909
+ });
910
+ });
911
+ describe("Plugin - new hooks wiring (chat.message, chat.params, tool.execute.before)", () => {
912
+ test("plugin returns chat.message, chat.params, and tool.execute.before hooks as functions", async () => {
913
+ const hooks = await entrypoint.server({
914
+ client: {},
915
+ project: {},
916
+ directory: testDir,
917
+ worktree: testDir,
918
+ experimental_workspace: { register: vi.fn() },
919
+ serverUrl: new URL("http://localhost"),
920
+ $: {},
921
+ });
922
+ expect(typeof hooks["chat.message"]).toBe("function");
923
+ expect(typeof hooks["chat.params"]).toBe("function");
924
+ expect(typeof hooks["tool.execute.before"]).toBe("function");
925
+ });
926
+ test("chat.message hook logs via logger.info and does not throw", async () => {
927
+ const infoSpy = vi
928
+ .spyOn(logger, "info")
929
+ .mockImplementation((() => logger));
930
+ try {
931
+ const hooks = await entrypoint.server({
932
+ client: {},
933
+ project: {},
934
+ directory: testDir,
935
+ worktree: testDir,
936
+ experimental_workspace: { register: vi.fn() },
937
+ serverUrl: new URL("http://localhost"),
938
+ $: {},
939
+ });
940
+ const sessionId = "chat-message-test";
941
+ await createSessionViaEvent(hooks, sessionId, testDir, "ChatMsg");
942
+ await expect(hooks["chat.message"]({
943
+ sessionID: sessionId,
944
+ messageID: "msg-1",
945
+ agent: "build",
946
+ }, { message: {}, parts: [] })).resolves.toBeUndefined();
947
+ const chatMessageCalls = infoSpy.mock.calls.filter((c) => c[0] === "chat.message");
948
+ expect(chatMessageCalls.length).toBeGreaterThan(0);
949
+ const payload = chatMessageCalls[0][1];
950
+ expect(payload.sessionID).toBe(sessionId);
951
+ expect(payload.messageID).toBe("msg-1");
952
+ expect(payload.agent).toBe("build");
953
+ }
954
+ finally {
955
+ infoSpy.mockRestore();
956
+ }
957
+ });
958
+ test("chat.params hook logs via logger.info and does not throw", async () => {
959
+ const infoSpy = vi
960
+ .spyOn(logger, "info")
961
+ .mockImplementation((() => logger));
962
+ try {
963
+ const hooks = await entrypoint.server({
964
+ client: {},
965
+ project: {},
966
+ directory: testDir,
967
+ worktree: testDir,
968
+ experimental_workspace: { register: vi.fn() },
969
+ serverUrl: new URL("http://localhost"),
970
+ $: {},
971
+ });
972
+ const sessionId = "chat-params-test";
973
+ await createSessionViaEvent(hooks, sessionId, testDir, "ChatParams");
974
+ await expect(hooks["chat.params"]({
975
+ sessionID: sessionId,
976
+ agent: "build",
977
+ model: { providerID: "anthropic", modelID: "claude-3-5-sonnet" },
978
+ provider: { source: "config", info: {}, options: {} },
979
+ message: {},
980
+ }, {
981
+ temperature: 0.7,
982
+ topP: 0.9,
983
+ topK: 40,
984
+ maxOutputTokens: 100,
985
+ options: {},
986
+ })).resolves.toBeUndefined();
987
+ const chatParamsCalls = infoSpy.mock.calls.filter((c) => c[0] === "chat.params");
988
+ expect(chatParamsCalls.length).toBeGreaterThan(0);
989
+ const payload = chatParamsCalls[0][1];
990
+ expect(payload.sessionID).toBe(sessionId);
991
+ expect(payload.agent).toBe("build");
992
+ }
993
+ finally {
994
+ infoSpy.mockRestore();
995
+ }
996
+ });
997
+ test("tool.execute.before hook logs via logger.info and does not throw", async () => {
998
+ const infoSpy = vi
999
+ .spyOn(logger, "info")
1000
+ .mockImplementation((() => logger));
1001
+ try {
1002
+ const hooks = await entrypoint.server({
1003
+ client: {},
1004
+ project: {},
1005
+ directory: testDir,
1006
+ worktree: testDir,
1007
+ experimental_workspace: { register: vi.fn() },
1008
+ serverUrl: new URL("http://localhost"),
1009
+ $: {},
1010
+ });
1011
+ const sessionId = "tool-before-test";
1012
+ await createSessionViaEvent(hooks, sessionId, testDir, "ToolBefore");
1013
+ await expect(hooks["tool.execute.before"]({ tool: "bash", sessionID: sessionId, callID: "call-1" }, { args: { command: "ls" } })).resolves.toBeUndefined();
1014
+ const toolBeforeCalls = infoSpy.mock.calls.filter((c) => c[0] === "tool.execute.before");
1015
+ expect(toolBeforeCalls.length).toBeGreaterThan(0);
1016
+ const payload = toolBeforeCalls[0][1];
1017
+ expect(payload.sessionID).toBe(sessionId);
1018
+ expect(payload.callID).toBe("call-1");
1019
+ expect(payload.tool).toBe("bash");
1020
+ }
1021
+ finally {
1022
+ infoSpy.mockRestore();
1023
+ }
1024
+ });
1025
+ });
1026
+ describe("Plugin - tool.execute.after parentID eager propagation", () => {
1027
+ test("eagerly writes child session's parentID via updateSessionMetadata when task tool completes", async () => {
1028
+ const { hooks } = await setupPluginWithMockClient(testDir);
1029
+ const parentSessionId = "parent-eager-pid";
1030
+ const childSessionId = "child-eager-pid";
1031
+ await createSessionViaEvent(hooks, parentSessionId, testDir, "Parent");
1032
+ await hooks["tool.execute.after"]({
1033
+ tool: "task",
1034
+ sessionID: parentSessionId,
1035
+ callID: "call-eager",
1036
+ args: { description: "sub-work", prompt: "do it" },
1037
+ }, {
1038
+ title: "Task done",
1039
+ output: "completed",
1040
+ metadata: { session_id: childSessionId },
1041
+ });
1042
+ const childMeta = readSessionMetadata(testDir, childSessionId);
1043
+ expect(childMeta).toBeTruthy();
1044
+ expect(childMeta.parentID).toBe(parentSessionId);
1045
+ const parentMeta = readSessionMetadata(testDir, parentSessionId);
1046
+ expect(parentMeta.subSessions).toContain(childSessionId);
1047
+ });
1048
+ test("does not eagerly write parentID for non-task tools", async () => {
1049
+ const { hooks } = await setupPluginWithMockClient(testDir);
1050
+ const sessionId = "non-task-eager";
1051
+ await createSessionViaEvent(hooks, sessionId, testDir, "Parent");
1052
+ const ghostChild = "ghost-child-eager";
1053
+ await hooks["tool.execute.after"]({
1054
+ tool: "read",
1055
+ sessionID: sessionId,
1056
+ callID: "call-read",
1057
+ args: { filePath: "/foo" },
1058
+ }, {
1059
+ title: "Read done",
1060
+ output: "contents",
1061
+ metadata: { session_id: ghostChild },
1062
+ });
1063
+ const ghostMeta = readSessionMetadata(testDir, ghostChild);
1064
+ if (ghostMeta !== null) {
1065
+ expect(ghostMeta.parentID).toBeUndefined();
1066
+ }
1067
+ });
1068
+ test("does not throw when task metadata has no session_id", async () => {
1069
+ const { hooks } = await setupPluginWithMockClient(testDir);
1070
+ const sessionId = "task-no-sid-eager";
1071
+ await createSessionViaEvent(hooks, sessionId, testDir, "Parent");
1072
+ await expect(hooks["tool.execute.after"]({
1073
+ tool: "task",
1074
+ sessionID: sessionId,
1075
+ callID: "call-no-sid-eager",
1076
+ args: {},
1077
+ }, {
1078
+ title: "Task done",
1079
+ output: "completed",
1080
+ metadata: {},
1081
+ })).resolves.toBeUndefined();
499
1082
  });
500
1083
  });
501
1084
  //# sourceMappingURL=trace.test.js.map