letagents 0.12.3 → 0.12.4
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/README.md +7 -1
- package/dist/mcp/agent-presence.js +5 -1
- package/dist/mcp/server.js +41 -12
- package/dist/shared/poll-timeout-cap.js +19 -0
- package/dist/shared/room-participant.js +20 -0
- package/package.json +3 -2
- package/dist/mcp/__tests__/config-reader.test.js +0 -93
- package/dist/mcp/__tests__/git-remote.test.js +0 -37
- package/dist/mcp/__tests__/server-helpers.test.js +0 -121
package/README.md
CHANGED
|
@@ -95,7 +95,7 @@ That local state stores:
|
|
|
95
95
|
| `get_current_room` | Show current room and how it was joined |
|
|
96
96
|
| `send_message` | Send a message to the current room or a specific `room_id` |
|
|
97
97
|
| `read_messages` | Read all messages from the current room or a specific `room_id` |
|
|
98
|
-
| `wait_for_messages` | Long-poll for new messages |
|
|
98
|
+
| `wait_for_messages` | Long-poll for new messages (see **Long room watches** in `AGENTS.md` and `docs/AGENT_HANDOFF_LONG_RUNS_AND_HEADLESS.md`) |
|
|
99
99
|
| `get_onboarding_status` | Inspect local auth, pending device flow, and saved room session state |
|
|
100
100
|
| `start_device_auth` | Start GitHub Device Flow and save the pending request locally |
|
|
101
101
|
| `poll_device_auth` | Finish GitHub Device Flow, persist the LetAgents token, and optionally auto-join a room |
|
|
@@ -136,6 +136,8 @@ npm run dev:api
|
|
|
136
136
|
|
|
137
137
|
The API runs at `http://localhost:3001`. Point `LETAGENTS_API_URL` at your server.
|
|
138
138
|
|
|
139
|
+
Optional — **long room long-polls** (multi-hour `wait_for_messages` / `GET …/messages/poll`): set the **same** `LETAGENTS_POLL_MAX_MS` on **both** the API process and any MCP client you run from source (milliseconds; default `180000`). See **`docs/AGENT_HANDOFF_LONG_RUNS_AND_HEADLESS.md`**.
|
|
140
|
+
|
|
139
141
|
The API now uses PostgreSQL with Drizzle ORM. `DB_URL` must be set before starting the server or running migrations.
|
|
140
142
|
|
|
141
143
|
Useful database commands:
|
|
@@ -156,6 +158,10 @@ docker run --rm --name letagents-pg \
|
|
|
156
158
|
postgres:16-alpine
|
|
157
159
|
```
|
|
158
160
|
|
|
161
|
+
## Further documentation
|
|
162
|
+
|
|
163
|
+
- **`docs/AGENT_HANDOFF_LONG_RUNS_AND_HEADLESS.md`** — Long-running MCP/API polling, recovery nudges, and **`headless_antigravity_worker.mjs`** cascade resolve / scan / reuse (handoff for follow-up agents).
|
|
164
|
+
|
|
159
165
|
## Links
|
|
160
166
|
|
|
161
167
|
- 📦 [npm package](https://www.npmjs.com/package/letagents)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
const IDLE_STATUS_RE = /\b(idle|available|online|polling|monitoring|
|
|
1
|
+
const IDLE_STATUS_RE = /\b(idle|available|online|polling|monitoring|watch(?:ing)?|ready|standby)\b/i;
|
|
2
|
+
const IDLE_WAITING_STATUS_RE = /\b(?:awaiting|waiting)\s+(?:for\s+)?(?:tasks?|work|instructions?|direction|assignment|assignments|next(?:\s+task)?|queue)\b/i;
|
|
2
3
|
const REVIEWING_STATUS_RE = /\b(review|reviewing|approve|approval|approving)\b/i;
|
|
3
4
|
const BLOCKED_STATUS_RE = /\b(blocked|waiting|stuck)\b/i;
|
|
4
5
|
export function classifyPresenceStatusText(statusText, fallback = "working") {
|
|
@@ -6,6 +7,9 @@ export function classifyPresenceStatusText(statusText, fallback = "working") {
|
|
|
6
7
|
if (!normalized) {
|
|
7
8
|
return fallback;
|
|
8
9
|
}
|
|
10
|
+
if (IDLE_WAITING_STATUS_RE.test(normalized)) {
|
|
11
|
+
return "idle";
|
|
12
|
+
}
|
|
9
13
|
if (BLOCKED_STATUS_RE.test(normalized)) {
|
|
10
14
|
return "blocked";
|
|
11
15
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -18,6 +18,7 @@ import { buildRoomAgentPrompt, normalizeAgentPromptKind, } from "../shared/room-
|
|
|
18
18
|
import { inspectLocalCodexSession, startLocalCodexSession, stopLocalCodexSession, toPublicCodexLiveSession, } from "./codex-session.js";
|
|
19
19
|
import { classifyPresenceStatusText, deriveTaskPresenceStatus, getRoomIdentityPresenceCacheKey, } from "./agent-presence.js";
|
|
20
20
|
import { buildRoomEventsQueryString } from "./room-events-query.js";
|
|
21
|
+
import { getPollTimeoutCapMs } from "../shared/poll-timeout-cap.js";
|
|
21
22
|
let currentRoom = null;
|
|
22
23
|
let currentAgentIdentityKey = "";
|
|
23
24
|
let currentAgentIdentity = null;
|
|
@@ -1601,7 +1602,13 @@ server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted
|
|
|
1601
1602
|
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
1602
1603
|
options: {
|
|
1603
1604
|
method: "PATCH",
|
|
1604
|
-
body: JSON.stringify({
|
|
1605
|
+
body: JSON.stringify({
|
|
1606
|
+
status: "assigned",
|
|
1607
|
+
assignee: identity.actor_label,
|
|
1608
|
+
actor_label: identity.actor_label,
|
|
1609
|
+
actor_key: identity.canonical_key,
|
|
1610
|
+
assignee_agent_key: identity.canonical_key,
|
|
1611
|
+
}),
|
|
1605
1612
|
},
|
|
1606
1613
|
});
|
|
1607
1614
|
await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, {
|
|
@@ -1659,6 +1666,8 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
|
|
|
1659
1666
|
}
|
|
1660
1667
|
try {
|
|
1661
1668
|
const identity = getConversationIdentity(conversation_id) ?? await ensureAgentIdentity();
|
|
1669
|
+
const nextAssignee = status === "assigned" && !assignee ? identity.actor_label : assignee;
|
|
1670
|
+
const nextAssigneeAgentKey = nextAssignee === identity.actor_label ? identity.canonical_key : undefined;
|
|
1662
1671
|
const updated = await roomScopedApiCall({
|
|
1663
1672
|
room_id: targetRoomId,
|
|
1664
1673
|
project_id: targetProjectId,
|
|
@@ -1668,9 +1677,12 @@ server.tool("update_task", "Update a task's status or assignee. Status transitio
|
|
|
1668
1677
|
method: "PATCH",
|
|
1669
1678
|
body: JSON.stringify({
|
|
1670
1679
|
status,
|
|
1671
|
-
assignee:
|
|
1680
|
+
assignee: nextAssignee,
|
|
1681
|
+
assignee_agent_key: nextAssigneeAgentKey,
|
|
1672
1682
|
pr_url,
|
|
1673
1683
|
workflow_artifacts,
|
|
1684
|
+
actor_label: identity.actor_label,
|
|
1685
|
+
actor_key: identity.canonical_key,
|
|
1674
1686
|
}),
|
|
1675
1687
|
},
|
|
1676
1688
|
});
|
|
@@ -1706,7 +1718,7 @@ server.tool("complete_task", "Submit a task for review. Moves the task to 'in_re
|
|
|
1706
1718
|
pr_url: z.string().optional().describe("GitHub PR URL for the work"),
|
|
1707
1719
|
room_id: z.string().optional().describe("Canonical room ID. Defaults to current room."),
|
|
1708
1720
|
conversation_id: z.string().optional().describe("Optional conversation ID for per-conversation identity scoping."),
|
|
1709
|
-
}, async ({ task_id, pr_url, room_id, conversation_id
|
|
1721
|
+
}, async ({ task_id, pr_url, room_id, conversation_id }) => {
|
|
1710
1722
|
const targetRoomId = getTargetRoomId(room_id);
|
|
1711
1723
|
const targetProjectId = getFallbackProjectId();
|
|
1712
1724
|
if (!targetRoomId && !targetProjectId) {
|
|
@@ -1715,6 +1727,7 @@ server.tool("complete_task", "Submit a task for review. Moves the task to 'in_re
|
|
|
1715
1727
|
};
|
|
1716
1728
|
}
|
|
1717
1729
|
try {
|
|
1730
|
+
const identity = getConversationIdentity(conversation_id) ?? await ensureAgentIdentity();
|
|
1718
1731
|
const updated = await roomScopedApiCall({
|
|
1719
1732
|
room_id: targetRoomId,
|
|
1720
1733
|
project_id: targetProjectId,
|
|
@@ -1722,11 +1735,27 @@ server.tool("complete_task", "Submit a task for review. Moves the task to 'in_re
|
|
|
1722
1735
|
project_path: (targetProjectId) => `/projects/${encodeURIComponent(targetProjectId)}/tasks/${encodeURIComponent(task_id)}`,
|
|
1723
1736
|
options: {
|
|
1724
1737
|
method: "PATCH",
|
|
1725
|
-
body: JSON.stringify({
|
|
1738
|
+
body: JSON.stringify({
|
|
1739
|
+
status: "in_review",
|
|
1740
|
+
pr_url,
|
|
1741
|
+
actor_label: identity.actor_label,
|
|
1742
|
+
actor_key: identity.canonical_key,
|
|
1743
|
+
}),
|
|
1726
1744
|
},
|
|
1727
1745
|
});
|
|
1746
|
+
await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, {
|
|
1747
|
+
status: "reviewing",
|
|
1748
|
+
status_text: `${task_id} -> in_review`,
|
|
1749
|
+
});
|
|
1728
1750
|
return {
|
|
1729
|
-
content: [{
|
|
1751
|
+
content: [{
|
|
1752
|
+
type: "text",
|
|
1753
|
+
text: JSON.stringify({
|
|
1754
|
+
success: true,
|
|
1755
|
+
task: updated,
|
|
1756
|
+
agent_identity: toPublicAgentIdentity(identity),
|
|
1757
|
+
}, null, 2),
|
|
1758
|
+
}],
|
|
1730
1759
|
};
|
|
1731
1760
|
}
|
|
1732
1761
|
catch (error) {
|
|
@@ -1880,7 +1909,7 @@ server.tool("initialize_repo", "Initialize the current repo for Let Agents Chat
|
|
|
1880
1909
|
}
|
|
1881
1910
|
});
|
|
1882
1911
|
// -- send_message -----------------------------------------------------------
|
|
1883
|
-
server.tool("send_message", "Send a message to a Let Agents Chat room.", {
|
|
1912
|
+
server.tool("send_message", "Send a message to a Let Agents Chat room. Use a short nudge (e.g. continue the plan) if another participant stopped after a closing message — silence does not always mean work is finished.", {
|
|
1884
1913
|
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
1885
1914
|
sender: z
|
|
1886
1915
|
.string()
|
|
@@ -1927,7 +1956,7 @@ server.tool("send_message", "Send a message to a Let Agents Chat room.", {
|
|
|
1927
1956
|
};
|
|
1928
1957
|
});
|
|
1929
1958
|
// -- read_messages ----------------------------------------------------------
|
|
1930
|
-
server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
|
|
1959
|
+
server.tool("read_messages", "Read all messages from a Let Agents Chat room. For long-running work, prefer wait_for_messages with after_message_id so you only process new lines and do not treat an empty poll as the end of the mission.", {
|
|
1931
1960
|
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
1932
1961
|
}, async ({ room_id }) => {
|
|
1933
1962
|
const targetRoomId = getTargetRoomId(room_id);
|
|
@@ -1973,9 +2002,8 @@ server.tool("read_messages", "Read all messages from a Let Agents Chat room.", {
|
|
|
1973
2002
|
};
|
|
1974
2003
|
});
|
|
1975
2004
|
// -- wait_for_messages ------------------------------------------------------
|
|
1976
|
-
const MAX_POLL_TIMEOUT_MS = 180000; // 3 minutes
|
|
1977
2005
|
const DEFAULT_POLL_TIMEOUT_MS = 30000; // 30 seconds
|
|
1978
|
-
server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat room.
|
|
2006
|
+
server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat room (HTTP long-poll). For multi-hour runs, call in a loop: always pass after_message_id from the last message you processed so an empty result means 'nothing new yet', not 'stop working'. If someone posted a premature 'I will wait' closing line, use send_message with a brief continue instruction. Per-call wait is capped (default max 180s unless LETAGENTS_POLL_MAX_MS is set on API and MCP).", {
|
|
1979
2007
|
room_id: z.string().optional().describe("Canonical room ID. Defaults to the current room."),
|
|
1980
2008
|
after_message_id: z
|
|
1981
2009
|
.string()
|
|
@@ -1984,14 +2012,15 @@ server.tool("wait_for_messages", "Wait for new messages in a Let Agents Chat roo
|
|
|
1984
2012
|
timeout: z
|
|
1985
2013
|
.number()
|
|
1986
2014
|
.optional()
|
|
1987
|
-
.describe("Maximum wait time in milliseconds. If set to 0, the default timeout will be used."),
|
|
2015
|
+
.describe("Maximum wait time in milliseconds (min 1000, capped by LETAGENTS_POLL_MAX_MS / server). If set to 0, the default timeout will be used."),
|
|
1988
2016
|
}, async ({ room_id, after_message_id, timeout }) => {
|
|
1989
2017
|
const targetRoomId = getTargetRoomId(room_id);
|
|
1990
2018
|
const targetProjectId = getFallbackProjectId();
|
|
1991
2019
|
const identity = await ensureAgentIdentity();
|
|
1992
2020
|
await heartbeatRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity);
|
|
1993
|
-
const
|
|
1994
|
-
const
|
|
2021
|
+
const maxPollMs = getPollTimeoutCapMs();
|
|
2022
|
+
const serverTimeout = Math.min(Math.max(timeout || DEFAULT_POLL_TIMEOUT_MS, 1000), maxPollMs);
|
|
2023
|
+
const clientTimeout = serverTimeout + (serverTimeout > 120_000 ? 120_000 : 5_000);
|
|
1995
2024
|
const params = new URLSearchParams();
|
|
1996
2025
|
if (after_message_id)
|
|
1997
2026
|
params.set("after", after_message_id);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upper bound for `GET .../messages/poll` wait duration and MCP `wait_for_messages` timeout.
|
|
3
|
+
*
|
|
4
|
+
* Default **180000** (3 minutes). Operators may raise it (e.g. **36000000** for 10 hours) by
|
|
5
|
+
* setting **`LETAGENTS_POLL_MAX_MS`** on both the API process and the MCP server process.
|
|
6
|
+
* Values are clamped to **24 hours** to avoid accidental runaway timers.
|
|
7
|
+
*/
|
|
8
|
+
export function getPollTimeoutCapMs() {
|
|
9
|
+
const raw = process.env.LETAGENTS_POLL_MAX_MS;
|
|
10
|
+
if (raw == null || raw === "") {
|
|
11
|
+
return 180_000;
|
|
12
|
+
}
|
|
13
|
+
const n = Number.parseInt(String(raw), 10);
|
|
14
|
+
if (Number.isNaN(n) || n < 1_000) {
|
|
15
|
+
return 180_000;
|
|
16
|
+
}
|
|
17
|
+
const ceiling = 86_400_000; // 24 hours
|
|
18
|
+
return Math.min(n, ceiling);
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { parseAgentActorLabel } from "./agent-identity.js";
|
|
2
|
+
export const ROOM_PARTICIPANT_KINDS = ["human", "agent"];
|
|
3
|
+
function normalizeParticipantKeyPart(value) {
|
|
4
|
+
return String(value ?? "")
|
|
5
|
+
.trim()
|
|
6
|
+
.replace(/\s+/g, " ")
|
|
7
|
+
.toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
export function buildAgentRoomParticipantKey(actorLabel) {
|
|
10
|
+
const normalizedActorLabel = normalizeParticipantKeyPart(parseAgentActorLabel(actorLabel)?.raw ?? actorLabel);
|
|
11
|
+
return normalizedActorLabel ? `agent:${normalizedActorLabel}` : null;
|
|
12
|
+
}
|
|
13
|
+
export function buildHumanRoomParticipantKey(input) {
|
|
14
|
+
const loginKey = normalizeParticipantKeyPart(input.github_login);
|
|
15
|
+
if (loginKey) {
|
|
16
|
+
return `human:login:${loginKey}`;
|
|
17
|
+
}
|
|
18
|
+
const displayNameKey = normalizeParticipantKeyPart(input.display_name);
|
|
19
|
+
return displayNameKey ? `human:name:${displayNameKey}` : null;
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letagents",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.4",
|
|
4
4
|
"description": "Let Agents Chat — MCP server for AI agent communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/mcp/server.js",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"@types/pg": "^8.18.0",
|
|
49
49
|
"drizzle-kit": "^0.31.10",
|
|
50
50
|
"tsx": "^4.19.4",
|
|
51
|
-
"typescript": "^5.8.3"
|
|
51
|
+
"typescript": "^5.8.3",
|
|
52
|
+
"ws": "^8.20.0"
|
|
52
53
|
}
|
|
53
54
|
}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { findLetagentsConfig, getRoomFromConfig } from "../config-reader";
|
|
2
|
-
import { mkdirSync, writeFileSync, rmSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
4
|
-
import { tmpdir } from "os";
|
|
5
|
-
// Helper to create temp directories with config files
|
|
6
|
-
function createTempDir() {
|
|
7
|
-
const dir = join(tmpdir(), `letagents-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
8
|
-
mkdirSync(dir, { recursive: true });
|
|
9
|
-
return dir;
|
|
10
|
-
}
|
|
11
|
-
function cleanup(dir) {
|
|
12
|
-
try {
|
|
13
|
-
rmSync(dir, { recursive: true, force: true });
|
|
14
|
-
}
|
|
15
|
-
catch {
|
|
16
|
-
// ignore cleanup errors
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
describe("findLetagentsConfig", () => {
|
|
20
|
-
let tempDir;
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
if (tempDir)
|
|
23
|
-
cleanup(tempDir);
|
|
24
|
-
});
|
|
25
|
-
it("returns config when .letagents.json is in the start directory", () => {
|
|
26
|
-
tempDir = createTempDir();
|
|
27
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "github.com/EmmyMay/letagents" }));
|
|
28
|
-
const config = findLetagentsConfig(tempDir);
|
|
29
|
-
expect(config).toEqual({ room: "github.com/EmmyMay/letagents" });
|
|
30
|
-
});
|
|
31
|
-
it("walks up to find config in parent directory", () => {
|
|
32
|
-
tempDir = createTempDir();
|
|
33
|
-
const childDir = join(tempDir, "src", "mcp");
|
|
34
|
-
mkdirSync(childDir, { recursive: true });
|
|
35
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "gitlab.com/team/project" }));
|
|
36
|
-
const config = findLetagentsConfig(childDir);
|
|
37
|
-
expect(config).toEqual({ room: "gitlab.com/team/project" });
|
|
38
|
-
});
|
|
39
|
-
it("returns null when no config file exists", () => {
|
|
40
|
-
tempDir = createTempDir();
|
|
41
|
-
const config = findLetagentsConfig(tempDir);
|
|
42
|
-
expect(config).toBeNull();
|
|
43
|
-
});
|
|
44
|
-
it("returns null for config with missing room field", () => {
|
|
45
|
-
tempDir = createTempDir();
|
|
46
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ version: "1.0" }));
|
|
47
|
-
// Suppress console.error for this test
|
|
48
|
-
const spy = jest.spyOn(console, "error").mockImplementation();
|
|
49
|
-
const config = findLetagentsConfig(tempDir);
|
|
50
|
-
expect(config).toBeNull();
|
|
51
|
-
spy.mockRestore();
|
|
52
|
-
});
|
|
53
|
-
it("returns null for config with empty room field", () => {
|
|
54
|
-
tempDir = createTempDir();
|
|
55
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "" }));
|
|
56
|
-
const spy = jest.spyOn(console, "error").mockImplementation();
|
|
57
|
-
const config = findLetagentsConfig(tempDir);
|
|
58
|
-
expect(config).toBeNull();
|
|
59
|
-
spy.mockRestore();
|
|
60
|
-
});
|
|
61
|
-
it("returns null for invalid JSON", () => {
|
|
62
|
-
tempDir = createTempDir();
|
|
63
|
-
writeFileSync(join(tempDir, ".letagents.json"), "not valid json {{{");
|
|
64
|
-
const spy = jest.spyOn(console, "error").mockImplementation();
|
|
65
|
-
const config = findLetagentsConfig(tempDir);
|
|
66
|
-
expect(config).toBeNull();
|
|
67
|
-
spy.mockRestore();
|
|
68
|
-
});
|
|
69
|
-
it("trims whitespace from room name", () => {
|
|
70
|
-
tempDir = createTempDir();
|
|
71
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: " github.com/EmmyMay/letagents " }));
|
|
72
|
-
const config = findLetagentsConfig(tempDir);
|
|
73
|
-
expect(config).toEqual({ room: "github.com/EmmyMay/letagents" });
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
describe("getRoomFromConfig", () => {
|
|
77
|
-
let tempDir;
|
|
78
|
-
afterEach(() => {
|
|
79
|
-
if (tempDir)
|
|
80
|
-
cleanup(tempDir);
|
|
81
|
-
});
|
|
82
|
-
it("returns room string when config exists", () => {
|
|
83
|
-
tempDir = createTempDir();
|
|
84
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "github.com/EmmyMay/letagents" }));
|
|
85
|
-
const room = getRoomFromConfig(tempDir);
|
|
86
|
-
expect(room).toBe("github.com/EmmyMay/letagents");
|
|
87
|
-
});
|
|
88
|
-
it("returns null when no config exists", () => {
|
|
89
|
-
tempDir = createTempDir();
|
|
90
|
-
const room = getRoomFromConfig(tempDir);
|
|
91
|
-
expect(room).toBeNull();
|
|
92
|
-
});
|
|
93
|
-
});
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { normalizeGitRemote } from "../git-remote";
|
|
2
|
-
describe("normalizeGitRemote", () => {
|
|
3
|
-
// SSH format tests
|
|
4
|
-
it("normalizes SSH git@github.com format", () => {
|
|
5
|
-
expect(normalizeGitRemote("git@github.com:EmmyMay/letagents.git")).toBe("github.com/EmmyMay/letagents");
|
|
6
|
-
});
|
|
7
|
-
it("normalizes SSH without .git suffix", () => {
|
|
8
|
-
expect(normalizeGitRemote("git@github.com:EmmyMay/letagents")).toBe("github.com/EmmyMay/letagents");
|
|
9
|
-
});
|
|
10
|
-
it("normalizes SSH with gitlab host", () => {
|
|
11
|
-
expect(normalizeGitRemote("git@gitlab.com:team/project.git")).toBe("gitlab.com/team/project");
|
|
12
|
-
});
|
|
13
|
-
// HTTPS format tests
|
|
14
|
-
it("normalizes HTTPS with .git suffix", () => {
|
|
15
|
-
expect(normalizeGitRemote("https://github.com/EmmyMay/letagents.git")).toBe("github.com/EmmyMay/letagents");
|
|
16
|
-
});
|
|
17
|
-
it("normalizes HTTPS without .git suffix", () => {
|
|
18
|
-
expect(normalizeGitRemote("https://github.com/EmmyMay/letagents")).toBe("github.com/EmmyMay/letagents");
|
|
19
|
-
});
|
|
20
|
-
it("normalizes HTTPS with trailing slash", () => {
|
|
21
|
-
expect(normalizeGitRemote("https://github.com/EmmyMay/letagents/")).toBe("github.com/EmmyMay/letagents");
|
|
22
|
-
});
|
|
23
|
-
// SSH protocol format tests
|
|
24
|
-
it("normalizes ssh:// protocol format", () => {
|
|
25
|
-
expect(normalizeGitRemote("ssh://git@gitlab.com/team/project.git")).toBe("gitlab.com/team/project");
|
|
26
|
-
});
|
|
27
|
-
// Edge cases
|
|
28
|
-
it("handles whitespace", () => {
|
|
29
|
-
expect(normalizeGitRemote(" git@github.com:EmmyMay/letagents.git ")).toBe("github.com/EmmyMay/letagents");
|
|
30
|
-
});
|
|
31
|
-
it("handles nested paths", () => {
|
|
32
|
-
expect(normalizeGitRemote("https://gitlab.com/org/sub-group/project.git")).toBe("gitlab.com/org/sub-group/project");
|
|
33
|
-
});
|
|
34
|
-
it("handles Bitbucket SSH format", () => {
|
|
35
|
-
expect(normalizeGitRemote("git@bitbucket.org:workspace/repo.git")).toBe("bitbucket.org/workspace/repo");
|
|
36
|
-
});
|
|
37
|
-
});
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for server helper functions:
|
|
3
|
-
* - resolveGitRoot: resolves the root of a git repo from any subdirectory
|
|
4
|
-
* - findExistingConfig: walks parent dirs to find .letagents.json
|
|
5
|
-
*
|
|
6
|
-
* These helpers underpin the corrected initialize_repo tool behavior.
|
|
7
|
-
* @author Kingdavid Ehindero <kdof64squares@gmail.com>
|
|
8
|
-
*/
|
|
9
|
-
import { execSync } from "child_process";
|
|
10
|
-
import { existsSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
11
|
-
import { join, resolve } from "path";
|
|
12
|
-
import { tmpdir } from "os";
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// Re-implement helpers here (they are not exported from server.ts yet)
|
|
15
|
-
// We test the logic directly until we extract them to a shared module.
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
function resolveGitRoot(dir) {
|
|
18
|
-
try {
|
|
19
|
-
const root = execSync("git rev-parse --show-toplevel", {
|
|
20
|
-
cwd: dir,
|
|
21
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
22
|
-
encoding: "utf-8",
|
|
23
|
-
}).trim();
|
|
24
|
-
return root || null;
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
function findExistingConfig(startDir) {
|
|
31
|
-
const { dirname } = require("path");
|
|
32
|
-
let current = startDir;
|
|
33
|
-
while (true) {
|
|
34
|
-
if (existsSync(join(current, ".letagents.json")))
|
|
35
|
-
return current;
|
|
36
|
-
const parent = dirname(current);
|
|
37
|
-
if (parent === current)
|
|
38
|
-
break;
|
|
39
|
-
current = parent;
|
|
40
|
-
}
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
// Helpers for tests
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
function makeTempGitRepo() {
|
|
47
|
-
const dir = join(tmpdir(), `letagents-test-${Date.now()}`);
|
|
48
|
-
mkdirSync(dir, { recursive: true });
|
|
49
|
-
execSync("git init", { cwd: dir, stdio: "pipe" });
|
|
50
|
-
execSync("git commit --allow-empty -m init", { cwd: dir, stdio: "pipe" });
|
|
51
|
-
return dir;
|
|
52
|
-
}
|
|
53
|
-
function cleanup(dir) {
|
|
54
|
-
try {
|
|
55
|
-
rmSync(dir, { recursive: true, force: true });
|
|
56
|
-
}
|
|
57
|
-
catch { /* ignore */ }
|
|
58
|
-
}
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// resolveGitRoot tests
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
describe("resolveGitRoot", () => {
|
|
63
|
-
let repoDir;
|
|
64
|
-
beforeAll(() => { repoDir = makeTempGitRepo(); });
|
|
65
|
-
afterAll(() => cleanup(repoDir));
|
|
66
|
-
it("returns the repo root when called from repo root", () => {
|
|
67
|
-
const result = resolveGitRoot(repoDir);
|
|
68
|
-
expect(result).toBe(resolve(repoDir));
|
|
69
|
-
});
|
|
70
|
-
it("returns the repo root when called from a subdirectory", () => {
|
|
71
|
-
const subDir = join(repoDir, "src", "deep", "path");
|
|
72
|
-
mkdirSync(subDir, { recursive: true });
|
|
73
|
-
const result = resolveGitRoot(subDir);
|
|
74
|
-
expect(result).toBe(resolve(repoDir));
|
|
75
|
-
});
|
|
76
|
-
it("returns null when not inside a git repo", () => {
|
|
77
|
-
const nonRepoDir = join(tmpdir(), `no-git-${Date.now()}`);
|
|
78
|
-
mkdirSync(nonRepoDir, { recursive: true });
|
|
79
|
-
const result = resolveGitRoot(nonRepoDir);
|
|
80
|
-
cleanup(nonRepoDir);
|
|
81
|
-
expect(result).toBeNull();
|
|
82
|
-
});
|
|
83
|
-
it("returns null for a non-existent directory", () => {
|
|
84
|
-
const result = resolveGitRoot(join(tmpdir(), "does-not-exist-12345"));
|
|
85
|
-
expect(result).toBeNull();
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
// findExistingConfig tests
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
describe("findExistingConfig", () => {
|
|
92
|
-
let tempDir;
|
|
93
|
-
beforeEach(() => {
|
|
94
|
-
tempDir = join(tmpdir(), `letagents-cfg-test-${Date.now()}`);
|
|
95
|
-
mkdirSync(tempDir, { recursive: true });
|
|
96
|
-
});
|
|
97
|
-
afterEach(() => cleanup(tempDir));
|
|
98
|
-
it("returns null when no .letagents.json exists anywhere", () => {
|
|
99
|
-
const subDir = join(tempDir, "a", "b", "c");
|
|
100
|
-
mkdirSync(subDir, { recursive: true });
|
|
101
|
-
expect(findExistingConfig(subDir)).toBeNull();
|
|
102
|
-
});
|
|
103
|
-
it("finds config in the start directory", () => {
|
|
104
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "test" }));
|
|
105
|
-
expect(findExistingConfig(tempDir)).toBe(tempDir);
|
|
106
|
-
});
|
|
107
|
-
it("finds config in a parent directory when called from subdirectory", () => {
|
|
108
|
-
const subDir = join(tempDir, "nested", "path");
|
|
109
|
-
mkdirSync(subDir, { recursive: true });
|
|
110
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "test" }));
|
|
111
|
-
expect(findExistingConfig(subDir)).toBe(tempDir);
|
|
112
|
-
});
|
|
113
|
-
it("returns the closest config when multiple exist in the tree", () => {
|
|
114
|
-
const subDir = join(tempDir, "nested");
|
|
115
|
-
mkdirSync(subDir, { recursive: true });
|
|
116
|
-
// Config at root and at nested level — should find nested first
|
|
117
|
-
writeFileSync(join(tempDir, ".letagents.json"), JSON.stringify({ room: "root" }));
|
|
118
|
-
writeFileSync(join(subDir, ".letagents.json"), JSON.stringify({ room: "nested" }));
|
|
119
|
-
expect(findExistingConfig(subDir)).toBe(subDir);
|
|
120
|
-
});
|
|
121
|
-
});
|