@hayasaka7/haya-pet 0.1.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.
Files changed (131) hide show
  1. package/.gitattributes +34 -0
  2. package/.github/workflows/release.yml +61 -0
  3. package/LICENSE +21 -0
  4. package/README.md +247 -0
  5. package/apps/cli/src/haya-pet.js +395 -0
  6. package/apps/cli/test/haya-pet.test.mjs +339 -0
  7. package/apps/companion/README.md +83 -0
  8. package/apps/companion/package.json +17 -0
  9. package/apps/companion/src/main/display-manager.js +71 -0
  10. package/apps/companion/src/main/index.js +349 -0
  11. package/apps/companion/src/main/lock-file.js +52 -0
  12. package/apps/companion/src/main/panel-placement.js +45 -0
  13. package/apps/companion/src/main/pet-loader.js +2 -0
  14. package/apps/companion/src/main/position-store.js +3 -0
  15. package/apps/companion/src/main/preload.cjs +13 -0
  16. package/apps/companion/src/main/state-file.js +2 -0
  17. package/apps/companion/src/main/terminal-helper-client.js +79 -0
  18. package/apps/companion/src/main/terminal-locator.js +44 -0
  19. package/apps/companion/src/main/tray-menu.js +79 -0
  20. package/apps/companion/src/main/window-options.js +66 -0
  21. package/apps/companion/src/renderer/index.html +18 -0
  22. package/apps/companion/src/renderer/interaction-controller.js +114 -0
  23. package/apps/companion/src/renderer/pet-window.js +275 -0
  24. package/apps/companion/src/renderer/session-bubbles.js +138 -0
  25. package/apps/companion/src/renderer/styles.css +225 -0
  26. package/apps/companion/src/renderer/task-talk-window.js +141 -0
  27. package/apps/companion/test/display-manager.test.mjs +48 -0
  28. package/apps/companion/test/interaction-controller.test.mjs +107 -0
  29. package/apps/companion/test/panel-placement.test.mjs +60 -0
  30. package/apps/companion/test/position-store.test.mjs +54 -0
  31. package/apps/companion/test/state-file.test.mjs +52 -0
  32. package/apps/companion/test/terminal-helper-client.test.mjs +68 -0
  33. package/apps/companion/test/terminal-locator.test.mjs +35 -0
  34. package/apps/companion/test/tray-menu.test.mjs +45 -0
  35. package/apps/companion/test/window-options.test.mjs +62 -0
  36. package/apps/pet-preview/index.html +42 -0
  37. package/apps/pet-preview/src/preview-app.js +123 -0
  38. package/apps/pet-preview/src/preview-state.js +70 -0
  39. package/apps/pet-preview/src/preview.css +125 -0
  40. package/apps/pet-preview/test/preview-state.test.mjs +62 -0
  41. package/assets/fallback-pet/README.md +16 -0
  42. package/assets/fallback-pet/pet.json +13 -0
  43. package/docs/architecture.md +144 -0
  44. package/docs/known-issues.md +49 -0
  45. package/docs/publishing.md +48 -0
  46. package/docs/screenshots/README.md +7 -0
  47. package/docs/screenshots/folder-collapsed.png +0 -0
  48. package/docs/screenshots/hero.png +0 -0
  49. package/docs/screenshots/pet-overlay.png +0 -0
  50. package/docs/screenshots/session-bubbles.png +0 -0
  51. package/docs/screenshots/tray-menu.png +0 -0
  52. package/docs/troubleshooting.md +36 -0
  53. package/native/README.md +80 -0
  54. package/native/linux-window-helper/README.md +29 -0
  55. package/native/mac-window-helper/README.md +30 -0
  56. package/native/win-window-helper/Program.cs +312 -0
  57. package/native/win-window-helper/README.md +53 -0
  58. package/native/win-window-helper/win-window-helper.csproj +12 -0
  59. package/package.json +35 -0
  60. package/packages/adapters/src/adapter-info.js +61 -0
  61. package/packages/adapters/src/capabilities.js +39 -0
  62. package/packages/adapters/src/heuristics.js +114 -0
  63. package/packages/adapters/src/output-observer.js +164 -0
  64. package/packages/adapters/src/routing.js +86 -0
  65. package/packages/adapters/test/adapter-info.test.mjs +35 -0
  66. package/packages/adapters/test/capabilities.test.mjs +44 -0
  67. package/packages/adapters/test/heuristics.test.mjs +42 -0
  68. package/packages/adapters/test/output-observer.test.mjs +142 -0
  69. package/packages/adapters/test/routing.test.mjs +93 -0
  70. package/packages/app-state/src/state-file.js +53 -0
  71. package/packages/app-state/src/state.js +80 -0
  72. package/packages/app-state/test/state.test.mjs +36 -0
  73. package/packages/cli-core/src/companion-launcher.js +69 -0
  74. package/packages/cli-core/src/pty-runner.js +96 -0
  75. package/packages/cli-core/src/run-command.js +353 -0
  76. package/packages/cli-core/src/strip-ansi.js +16 -0
  77. package/packages/cli-core/test/companion-launcher.test.mjs +98 -0
  78. package/packages/cli-core/test/run-command.test.mjs +177 -0
  79. package/packages/cli-core/test/strip-ansi.test.mjs +27 -0
  80. package/packages/daemon-core/src/daemon-runtime.js +49 -0
  81. package/packages/daemon-core/src/ipc-server.js +180 -0
  82. package/packages/daemon-core/src/ipc-transport.js +70 -0
  83. package/packages/daemon-core/src/singleton.js +46 -0
  84. package/packages/daemon-core/test/daemon-runtime.test.mjs +65 -0
  85. package/packages/daemon-core/test/ipc-server.test.mjs +70 -0
  86. package/packages/daemon-core/test/ipc-transport.test.mjs +72 -0
  87. package/packages/daemon-core/test/singleton.test.mjs +32 -0
  88. package/packages/pet-core/src/animation-state.js +84 -0
  89. package/packages/pet-core/src/animator.js +26 -0
  90. package/packages/pet-core/src/atlas.js +81 -0
  91. package/packages/pet-core/src/discovery.js +90 -0
  92. package/packages/pet-core/src/manifest.js +112 -0
  93. package/packages/pet-core/src/validation.js +43 -0
  94. package/packages/pet-core/test/animation-state.test.mjs +47 -0
  95. package/packages/pet-core/test/animator.test.mjs +31 -0
  96. package/packages/pet-core/test/atlas.test.mjs +81 -0
  97. package/packages/pet-core/test/discovery.test.mjs +93 -0
  98. package/packages/pet-core/test/manifest.test.mjs +93 -0
  99. package/packages/pet-core/test/validation.test.mjs +69 -0
  100. package/packages/platform-core/src/capabilities.js +49 -0
  101. package/packages/platform-core/src/paths.js +75 -0
  102. package/packages/platform-core/src/platform.js +15 -0
  103. package/packages/platform-core/test/platform.test.mjs +84 -0
  104. package/packages/protocol/src/messages.js +156 -0
  105. package/packages/protocol/test/messages.test.mjs +112 -0
  106. package/packages/session-core/src/bubble-linger.js +47 -0
  107. package/packages/session-core/src/bubble-view.js +79 -0
  108. package/packages/session-core/src/pet-state.js +56 -0
  109. package/packages/session-core/src/priority.js +56 -0
  110. package/packages/session-core/src/registry.js +144 -0
  111. package/packages/session-core/src/summaries.js +54 -0
  112. package/packages/session-core/test/bubble-linger.test.mjs +96 -0
  113. package/packages/session-core/test/bubble-view.test.mjs +79 -0
  114. package/packages/session-core/test/pet-state.test.mjs +118 -0
  115. package/packages/session-core/test/priority.test.mjs +53 -0
  116. package/packages/session-core/test/registry.test.mjs +161 -0
  117. package/packages/session-core/test/summaries.test.mjs +38 -0
  118. package/packages/task-core/src/approvals.js +91 -0
  119. package/packages/task-core/src/controls.js +61 -0
  120. package/packages/task-core/src/replies.js +80 -0
  121. package/packages/task-core/src/task-events.js +101 -0
  122. package/packages/task-core/src/task-status.js +93 -0
  123. package/packages/task-core/src/task-store.js +74 -0
  124. package/packages/task-core/test/approvals.test.mjs +61 -0
  125. package/packages/task-core/test/controls.test.mjs +61 -0
  126. package/packages/task-core/test/replies.test.mjs +51 -0
  127. package/packages/task-core/test/task-events.test.mjs +67 -0
  128. package/packages/task-core/test/task-status.test.mjs +49 -0
  129. package/packages/task-core/test/task-store.test.mjs +65 -0
  130. package/test/harness.mjs +22 -0
  131. package/test/run-tests.mjs +47 -0
@@ -0,0 +1,61 @@
1
+ // Task control buttons (product plan section 23). The UI must hide or disable
2
+ // any control the selected adapter cannot safely support.
3
+ export const TASK_CONTROL_IDS = Object.freeze([
4
+ "focus_terminal",
5
+ "open_full",
6
+ "reply",
7
+ "approve",
8
+ "deny",
9
+ "continue",
10
+ "pause",
11
+ "resume",
12
+ "stop",
13
+ "restart",
14
+ "hide"
15
+ ]);
16
+
17
+ const CONTROL_LABELS = Object.freeze({
18
+ focus_terminal: "Focus Terminal",
19
+ open_full: "Open Full Session",
20
+ reply: "Reply",
21
+ approve: "Approve",
22
+ deny: "Deny",
23
+ continue: "Continue",
24
+ pause: "Pause",
25
+ resume: "Resume",
26
+ stop: "Stop",
27
+ restart: "Restart",
28
+ hide: "Hide"
29
+ });
30
+
31
+ export function resolveTaskControls(capabilities = {}, status) {
32
+ const hasPendingApproval = status === "waiting_approval";
33
+ const canReply = capabilities.canReply !== undefined && capabilities.canReply !== "unsupported";
34
+ const canApprove = capabilities.canApprove !== undefined && capabilities.canApprove !== "unsupported";
35
+
36
+ const enabledById = {
37
+ focus_terminal: Boolean(capabilities.canFocusTerminal),
38
+ open_full: Boolean(capabilities.canOpenTranscript),
39
+ reply: canReply,
40
+ approve: canApprove && hasPendingApproval,
41
+ deny: canApprove && hasPendingApproval,
42
+ continue: Boolean(capabilities.canResume),
43
+ pause: Boolean(capabilities.canPause),
44
+ resume: Boolean(capabilities.canResume),
45
+ stop: Boolean(capabilities.canStop),
46
+ restart: Boolean(capabilities.canStop),
47
+ hide: true
48
+ };
49
+
50
+ return TASK_CONTROL_IDS.map((id) => ({
51
+ id,
52
+ label: CONTROL_LABELS[id],
53
+ enabled: enabledById[id]
54
+ }));
55
+ }
56
+
57
+ export function listEnabledControls(capabilities, status) {
58
+ return resolveTaskControls(capabilities, status)
59
+ .filter((control) => control.enabled)
60
+ .map((control) => control.id);
61
+ }
@@ -0,0 +1,80 @@
1
+ export function buildTaskInputRequest({ sessionId, inputId, text, createdAt }) {
2
+ return {
3
+ type: "task_input",
4
+ sessionId,
5
+ inputId,
6
+ text,
7
+ createdAt
8
+ };
9
+ }
10
+
11
+ export function validateTaskInputRequest(input) {
12
+ if (!isPlainObject(input)) {
13
+ return { ok: false, errors: ["task input request must be an object"] };
14
+ }
15
+
16
+ const errors = [];
17
+
18
+ if (input.type !== "task_input") {
19
+ errors.push('type must be "task_input"');
20
+ }
21
+
22
+ requireNonEmptyString(errors, input.sessionId, "sessionId");
23
+ requireNonEmptyString(errors, input.inputId, "inputId");
24
+ requireNonEmptyString(errors, input.text, "text");
25
+
26
+ if (!Number.isFinite(input.createdAt)) {
27
+ errors.push("createdAt must be a finite number");
28
+ }
29
+
30
+ return { ok: errors.length === 0, errors };
31
+ }
32
+
33
+ export function validateTaskInputResult(input) {
34
+ if (!isPlainObject(input)) {
35
+ return { ok: false, errors: ["task input result must be an object"] };
36
+ }
37
+
38
+ const errors = [];
39
+
40
+ if (input.type !== "task_input_result") {
41
+ errors.push('type must be "task_input_result"');
42
+ }
43
+
44
+ requireNonEmptyString(errors, input.sessionId, "sessionId");
45
+ requireNonEmptyString(errors, input.inputId, "inputId");
46
+
47
+ if (typeof input.ok !== "boolean") {
48
+ errors.push("ok must be a boolean");
49
+ }
50
+
51
+ if (input.error !== undefined && typeof input.error !== "string") {
52
+ errors.push("error must be a string when provided");
53
+ }
54
+
55
+ return { ok: errors.length === 0, errors };
56
+ }
57
+
58
+ // Translate adapter reply capability into a safe UI mode. The reply button must
59
+ // never blindly type into a terminal when the adapter cannot verify the client
60
+ // is waiting for input (product plan section 21).
61
+ export function resolveReplyMode(capabilities = {}) {
62
+ switch (capabilities.canReply) {
63
+ case "supported":
64
+ return "send";
65
+ case "best_effort":
66
+ return "best-effort";
67
+ default:
68
+ return "open-terminal";
69
+ }
70
+ }
71
+
72
+ function requireNonEmptyString(errors, value, fieldName) {
73
+ if (typeof value !== "string" || value.trim() === "") {
74
+ errors.push(`${fieldName} must be a non-empty string`);
75
+ }
76
+ }
77
+
78
+ function isPlainObject(value) {
79
+ return typeof value === "object" && value !== null && !Array.isArray(value);
80
+ }
@@ -0,0 +1,101 @@
1
+ import { isStateSource } from "../../protocol/src/messages.js";
2
+ import { isTaskStatus } from "./task-status.js";
3
+
4
+ export const TASK_EVENT_TYPES = Object.freeze([
5
+ "user_message",
6
+ "assistant_message",
7
+ "assistant_delta",
8
+ "status_changed",
9
+ "tool_started",
10
+ "tool_output",
11
+ "tool_finished",
12
+ "file_changed",
13
+ "diff_ready",
14
+ "test_started",
15
+ "test_finished",
16
+ "approval_requested",
17
+ "approval_resolved",
18
+ "error",
19
+ "summary",
20
+ "task_completed"
21
+ ]);
22
+
23
+ const TASK_EVENT_TYPE_SET = new Set(TASK_EVENT_TYPES);
24
+
25
+ export function isTaskEventType(value) {
26
+ return TASK_EVENT_TYPE_SET.has(value);
27
+ }
28
+
29
+ export function normalizeTaskEvent(input) {
30
+ if (!isPlainObject(input)) {
31
+ return { ok: false, errors: ["event must be an object"] };
32
+ }
33
+
34
+ const errors = [];
35
+
36
+ requireNonEmptyString(errors, input.id, "id");
37
+ requireNonEmptyString(errors, input.sessionId, "sessionId");
38
+
39
+ if (!isTaskEventType(input.type)) {
40
+ errors.push(`type must be one of: ${TASK_EVENT_TYPES.join(", ")}`);
41
+ }
42
+
43
+ if (!Number.isFinite(input.timestamp)) {
44
+ errors.push("timestamp must be a finite number");
45
+ }
46
+
47
+ if (input.status !== undefined && !isTaskStatus(input.status)) {
48
+ errors.push("status must be a valid task status when provided");
49
+ }
50
+
51
+ if (input.source !== undefined && !isStateSource(input.source)) {
52
+ errors.push("source must be a valid state source when provided");
53
+ }
54
+
55
+ if (input.title !== undefined && typeof input.title !== "string") {
56
+ errors.push("title must be a string when provided");
57
+ }
58
+
59
+ if (input.text !== undefined && typeof input.text !== "string") {
60
+ errors.push("text must be a string when provided");
61
+ }
62
+
63
+ if (input.confidence !== undefined) {
64
+ if (!Number.isFinite(input.confidence) || input.confidence < 0 || input.confidence > 1) {
65
+ errors.push("confidence must be a number from 0 to 1 when provided");
66
+ }
67
+ }
68
+
69
+ if (errors.length > 0) {
70
+ return { ok: false, errors };
71
+ }
72
+
73
+ return { ok: true, errors: [], event: buildEvent(input) };
74
+ }
75
+
76
+ function buildEvent(input) {
77
+ const event = {
78
+ id: input.id,
79
+ sessionId: input.sessionId,
80
+ type: input.type,
81
+ timestamp: input.timestamp
82
+ };
83
+
84
+ for (const field of ["title", "text", "status", "confidence", "source", "payload"]) {
85
+ if (input[field] !== undefined) {
86
+ event[field] = input[field];
87
+ }
88
+ }
89
+
90
+ return event;
91
+ }
92
+
93
+ function requireNonEmptyString(errors, value, fieldName) {
94
+ if (typeof value !== "string" || value.trim() === "") {
95
+ errors.push(`${fieldName} must be a non-empty string`);
96
+ }
97
+ }
98
+
99
+ function isPlainObject(value) {
100
+ return typeof value === "object" && value !== null && !Array.isArray(value);
101
+ }
@@ -0,0 +1,93 @@
1
+ import { mapAiStateToPetAction } from "../../pet-core/src/atlas.js";
2
+
3
+ export const TASK_STATUSES = Object.freeze([
4
+ "starting",
5
+ "thinking",
6
+ "running",
7
+ "running_command",
8
+ "editing_files",
9
+ "searching",
10
+ "waiting_user",
11
+ "waiting_approval",
12
+ "reviewing",
13
+ "compacting",
14
+ "blocked",
15
+ "failed",
16
+ "completed",
17
+ "paused",
18
+ "stopped",
19
+ "stale",
20
+ "exited"
21
+ ]);
22
+
23
+ const TASK_STATUS_SET = new Set(TASK_STATUSES);
24
+
25
+ const TASK_STATUS_TO_AI_STATE = Object.freeze({
26
+ starting: "thinking",
27
+ thinking: "thinking",
28
+ running: "running_tool",
29
+ running_command: "running_tool",
30
+ editing_files: "editing_files",
31
+ searching: "running_tool",
32
+ waiting_user: "waiting_user",
33
+ waiting_approval: "waiting_approval",
34
+ reviewing: "reviewing",
35
+ compacting: "compacting",
36
+ blocked: "waiting_user",
37
+ failed: "failed",
38
+ completed: "success",
39
+ paused: "idle",
40
+ stopped: "exited",
41
+ stale: "stale",
42
+ exited: "exited"
43
+ });
44
+
45
+ const TASK_STATUS_PILLS = Object.freeze({
46
+ starting: "Starting",
47
+ thinking: "Thinking",
48
+ running: "Running",
49
+ running_command: "Running command",
50
+ editing_files: "Editing files",
51
+ searching: "Searching",
52
+ waiting_user: "Waiting for you",
53
+ waiting_approval: "Waiting for approval",
54
+ reviewing: "Reviewing",
55
+ compacting: "Compacting context",
56
+ blocked: "Blocked",
57
+ failed: "Failed",
58
+ completed: "Completed",
59
+ paused: "Paused",
60
+ stopped: "Stopped",
61
+ stale: "Stale",
62
+ exited: "Exited"
63
+ });
64
+
65
+ export function isTaskStatus(value) {
66
+ return TASK_STATUS_SET.has(value);
67
+ }
68
+
69
+ export function mapTaskStatusToAiState(status) {
70
+ const aiState = TASK_STATUS_TO_AI_STATE[status];
71
+ if (!aiState) {
72
+ throw new Error(`Unknown task status: ${status}`);
73
+ }
74
+
75
+ return aiState;
76
+ }
77
+
78
+ export function mapTaskStatusToPetAction(status) {
79
+ return mapAiStateToPetAction(mapTaskStatusToAiState(status));
80
+ }
81
+
82
+ export function buildTaskStatusPill(status) {
83
+ return TASK_STATUS_PILLS[status] ?? humanize(status);
84
+ }
85
+
86
+ function humanize(value) {
87
+ if (typeof value !== "string" || value === "") {
88
+ return "Unknown";
89
+ }
90
+
91
+ const text = value.replace(/_/g, " ");
92
+ return text.charAt(0).toUpperCase() + text.slice(1);
93
+ }
@@ -0,0 +1,74 @@
1
+ import { normalizeTaskEvent } from "./task-events.js";
2
+ import { mapTaskStatusToAiState, mapTaskStatusToPetAction } from "./task-status.js";
3
+
4
+ const DEFAULT_MAX_EVENTS = 200;
5
+
6
+ export function createTaskStore(options = {}) {
7
+ return new TaskStore(options);
8
+ }
9
+
10
+ class TaskStore {
11
+ constructor(options) {
12
+ this.maxEventsPerSession = options.maxEventsPerSession ?? DEFAULT_MAX_EVENTS;
13
+ this.events = new Map();
14
+ this.snapshots = new Map();
15
+ }
16
+
17
+ appendEvent(input) {
18
+ const result = normalizeTaskEvent(input);
19
+ if (!result.ok) {
20
+ throw new Error(result.errors.join("; "));
21
+ }
22
+
23
+ const event = result.event;
24
+ const list = this.events.get(event.sessionId) ?? [];
25
+ list.push(event);
26
+
27
+ while (list.length > this.maxEventsPerSession) {
28
+ list.shift();
29
+ }
30
+
31
+ this.events.set(event.sessionId, list);
32
+
33
+ if (event.status !== undefined) {
34
+ this.updateSnapshot(event);
35
+ }
36
+
37
+ return { ...event };
38
+ }
39
+
40
+ getEvents(sessionId, options = {}) {
41
+ const list = this.events.get(sessionId) ?? [];
42
+ const events = options.limit ? list.slice(-options.limit) : list.slice();
43
+ return events.map((event) => ({ ...event }));
44
+ }
45
+
46
+ getLatestEvent(sessionId) {
47
+ const list = this.events.get(sessionId);
48
+ if (!list || list.length === 0) {
49
+ return undefined;
50
+ }
51
+
52
+ return { ...list[list.length - 1] };
53
+ }
54
+
55
+ getStatusSnapshot(sessionId) {
56
+ const snapshot = this.snapshots.get(sessionId);
57
+ return snapshot ? { ...snapshot } : undefined;
58
+ }
59
+
60
+ updateSnapshot(event) {
61
+ const previous = this.snapshots.get(event.sessionId);
62
+
63
+ this.snapshots.set(event.sessionId, {
64
+ sessionId: event.sessionId,
65
+ status: event.status,
66
+ aiState: mapTaskStatusToAiState(event.status),
67
+ petAction: mapTaskStatusToPetAction(event.status),
68
+ confidence: Number.isFinite(event.confidence) ? event.confidence : previous?.confidence ?? 1,
69
+ source: event.source ?? previous?.source ?? "manual",
70
+ updatedAt: event.timestamp,
71
+ summary: event.text ?? previous?.summary
72
+ });
73
+ }
74
+ }
@@ -0,0 +1,61 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import {
4
+ APPROVAL_KINDS,
5
+ validateApprovalRequest,
6
+ buildApprovalDecision,
7
+ validateApprovalDecision
8
+ } from "../src/approvals.js";
9
+
10
+ test("validates an approval request with the documented fields", () => {
11
+ const result = validateApprovalRequest({
12
+ approvalId: "ap_1",
13
+ sessionId: "s",
14
+ kind: "command",
15
+ title: "Run npm test",
16
+ commandPreview: "npm test",
17
+ risk: "medium",
18
+ requestedAt: 100
19
+ });
20
+
21
+ assert.equal(result.ok, true);
22
+ assert.deepEqual(result.errors, []);
23
+ assert.ok(APPROVAL_KINDS.includes("file_write"));
24
+ });
25
+
26
+ test("rejects approval requests with invalid kind or risk", () => {
27
+ const result = validateApprovalRequest({
28
+ approvalId: "ap_1",
29
+ sessionId: "s",
30
+ kind: "launch_missiles",
31
+ title: "x",
32
+ risk: "extreme",
33
+ requestedAt: 1
34
+ });
35
+ assert.equal(result.ok, false);
36
+ assert.ok(result.errors.some((m) => m.includes("kind")));
37
+ assert.ok(result.errors.some((m) => m.includes("risk")));
38
+ });
39
+
40
+ test("builds and validates an approval decision", () => {
41
+ const decision = buildApprovalDecision({
42
+ sessionId: "s",
43
+ approvalId: "ap_1",
44
+ decision: "approve",
45
+ decidedAt: 200
46
+ });
47
+ assert.equal(decision.type, "approval_decision");
48
+ assert.equal(validateApprovalDecision(decision).ok, true);
49
+ });
50
+
51
+ test("rejects decisions that are not approve or deny", () => {
52
+ const result = validateApprovalDecision({
53
+ type: "approval_decision",
54
+ sessionId: "s",
55
+ approvalId: "ap_1",
56
+ decision: "maybe",
57
+ decidedAt: 1
58
+ });
59
+ assert.equal(result.ok, false);
60
+ assert.ok(result.errors.some((m) => m.includes("decision")));
61
+ });
@@ -0,0 +1,61 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { resolveTaskControls, listEnabledControls } from "../src/controls.js";
4
+
5
+ const WRAPPER_CAPS = {
6
+ canReply: "unsupported",
7
+ canApprove: "unsupported",
8
+ canPause: false,
9
+ canResume: false,
10
+ canStop: true,
11
+ canFocusTerminal: true,
12
+ canOpenTranscript: false,
13
+ canShowDiffs: false,
14
+ canShowFiles: false,
15
+ canShowTests: false
16
+ };
17
+
18
+ const PTY_CAPS = {
19
+ canReply: "best_effort",
20
+ canApprove: "best_effort",
21
+ canPause: false,
22
+ canResume: false,
23
+ canStop: true,
24
+ canFocusTerminal: true,
25
+ canOpenTranscript: false,
26
+ canShowDiffs: false,
27
+ canShowFiles: false,
28
+ canShowTests: false
29
+ };
30
+
31
+ test("disables reply and approval for wrapper-only adapters", () => {
32
+ const controls = resolveTaskControls(WRAPPER_CAPS, "waiting_approval");
33
+ const reply = controls.find((c) => c.id === "reply");
34
+ const approve = controls.find((c) => c.id === "approve");
35
+ assert.equal(reply.enabled, false);
36
+ assert.equal(approve.enabled, false);
37
+ const focus = controls.find((c) => c.id === "focus_terminal");
38
+ assert.equal(focus.enabled, true);
39
+ });
40
+
41
+ test("enables approval only when a pending approval exists", () => {
42
+ const waiting = resolveTaskControls(PTY_CAPS, "waiting_approval");
43
+ assert.equal(waiting.find((c) => c.id === "approve").enabled, true);
44
+
45
+ const running = resolveTaskControls(PTY_CAPS, "running_command");
46
+ assert.equal(running.find((c) => c.id === "approve").enabled, false);
47
+ });
48
+
49
+ test("listEnabledControls returns only enabled control ids", () => {
50
+ const enabled = listEnabledControls(WRAPPER_CAPS, "running_command");
51
+ assert.ok(enabled.includes("focus_terminal"));
52
+ assert.ok(enabled.includes("stop"));
53
+ assert.ok(enabled.includes("hide"));
54
+ assert.ok(!enabled.includes("reply"));
55
+ assert.ok(!enabled.includes("approve"));
56
+ });
57
+
58
+ test("always allows hide regardless of capabilities", () => {
59
+ const controls = resolveTaskControls(WRAPPER_CAPS, "idle");
60
+ assert.equal(controls.find((c) => c.id === "hide").enabled, true);
61
+ });
@@ -0,0 +1,51 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import {
4
+ buildTaskInputRequest,
5
+ validateTaskInputRequest,
6
+ validateTaskInputResult,
7
+ resolveReplyMode
8
+ } from "../src/replies.js";
9
+
10
+ test("builds and validates a task input request", () => {
11
+ const request = buildTaskInputRequest({
12
+ sessionId: "s",
13
+ inputId: "in_1",
14
+ text: "continue please",
15
+ createdAt: 10
16
+ });
17
+ assert.equal(request.type, "task_input");
18
+ assert.equal(validateTaskInputRequest(request).ok, true);
19
+ });
20
+
21
+ test("rejects empty reply text", () => {
22
+ const result = validateTaskInputRequest({
23
+ type: "task_input",
24
+ sessionId: "s",
25
+ inputId: "in_1",
26
+ text: " ",
27
+ createdAt: 1
28
+ });
29
+ assert.equal(result.ok, false);
30
+ assert.ok(result.errors.some((m) => m.includes("text")));
31
+ });
32
+
33
+ test("validates a task input result", () => {
34
+ assert.equal(
35
+ validateTaskInputResult({
36
+ type: "task_input_result",
37
+ sessionId: "s",
38
+ inputId: "in_1",
39
+ ok: true,
40
+ acceptedAt: 20
41
+ }).ok,
42
+ true
43
+ );
44
+ });
45
+
46
+ test("resolves the safe reply mode from adapter capability", () => {
47
+ assert.equal(resolveReplyMode({ canReply: "supported" }), "send");
48
+ assert.equal(resolveReplyMode({ canReply: "best_effort" }), "best-effort");
49
+ assert.equal(resolveReplyMode({ canReply: "unsupported" }), "open-terminal");
50
+ assert.equal(resolveReplyMode({}), "open-terminal");
51
+ });
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import {
4
+ TASK_EVENT_TYPES,
5
+ isTaskEventType,
6
+ normalizeTaskEvent
7
+ } from "../src/task-events.js";
8
+
9
+ test("defines the documented task event vocabulary", () => {
10
+ for (const type of [
11
+ "user_message",
12
+ "assistant_message",
13
+ "status_changed",
14
+ "tool_started",
15
+ "approval_requested",
16
+ "task_completed"
17
+ ]) {
18
+ assert.ok(TASK_EVENT_TYPES.includes(type), `missing ${type}`);
19
+ }
20
+ assert.equal(isTaskEventType("tool_output"), true);
21
+ assert.equal(isTaskEventType("nope"), false);
22
+ });
23
+
24
+ test("normalizes a valid task event", () => {
25
+ const result = normalizeTaskEvent({
26
+ id: "evt_1",
27
+ sessionId: "sess_a",
28
+ type: "status_changed",
29
+ timestamp: 1000,
30
+ status: "running_command",
31
+ source: "pty_output",
32
+ text: "running tests"
33
+ });
34
+
35
+ assert.equal(result.ok, true);
36
+ assert.deepEqual(result.errors, []);
37
+ assert.equal(result.event.type, "status_changed");
38
+ assert.equal(result.event.status, "running_command");
39
+ });
40
+
41
+ test("rejects events with missing or invalid fields", () => {
42
+ const missing = normalizeTaskEvent({ type: "user_message" });
43
+ assert.equal(missing.ok, false);
44
+ assert.ok(missing.errors.some((m) => m.includes("id")));
45
+ assert.ok(missing.errors.some((m) => m.includes("sessionId")));
46
+
47
+ const badType = normalizeTaskEvent({
48
+ id: "e",
49
+ sessionId: "s",
50
+ type: "explode",
51
+ timestamp: 1
52
+ });
53
+ assert.equal(badType.ok, false);
54
+ assert.ok(badType.errors.some((m) => m.includes("type")));
55
+ });
56
+
57
+ test("rejects an invalid status on an otherwise valid event", () => {
58
+ const result = normalizeTaskEvent({
59
+ id: "e",
60
+ sessionId: "s",
61
+ type: "status_changed",
62
+ timestamp: 1,
63
+ status: "not-a-status"
64
+ });
65
+ assert.equal(result.ok, false);
66
+ assert.ok(result.errors.some((m) => m.includes("status")));
67
+ });