@vitest-agent/mcp 2.0.4 → 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/vitest-agent-mcp.js +2 -1
- package/context.js +15 -2
- package/index.d.ts +58 -6
- package/index.js +4 -3
- package/package.json +2 -2
- package/server.js +27 -9
- package/session-env.js +112 -0
- package/tools/hypothesis.js +25 -13
- package/tsdoc-metadata.json +1 -1
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ The `vitest-agent-mcp` MCP server bin. Exposes action-keyed tools over stdio tha
|
|
|
13
13
|
- **29 action-keyed tools** — per-CRUD families collapse into single tools dispatching on an `action` discriminator; covers `test_status`, `test_overview`, `test_coverage`, `test_errors`, `run_tests`, `note`, `hypothesis`, `tdd_task`, `tdd_goal`, `tdd_behavior` and more
|
|
14
14
|
- **Six framing prompts** — `triage`, `why-flaky`, `regression-since-pass`, `explain-failure`, `tdd-resume`, `wrapup`
|
|
15
15
|
- **Idempotency middleware** — `tdd_task`, `tdd_goal`, `tdd_behavior` and `hypothesis` create-actions are idempotent on derived keys
|
|
16
|
+
- **Programmatic API** — `buildMcpServer` constructs the server without connecting a transport; `parseSessionEnvExports` and `recoverSessionContextFromSessionEnv` recover the host session context from the Claude Code plugin's session-env files, so agent attribution survives a plugin reload
|
|
16
17
|
|
|
17
18
|
## Install
|
|
18
19
|
|
package/bin/vitest-agent-mcp.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { createCurrentSessionIdRef, createSessionContextRef, sessionContextFromEnv } from "../context.js";
|
|
3
3
|
import { McpLive } from "../layers/McpLive.js";
|
|
4
4
|
import { startMcpServer } from "../server.js";
|
|
5
|
+
import { recoverSessionContextFromSessionEnv } from "../session-env.js";
|
|
5
6
|
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
6
7
|
import { PathResolutionLive, formatFatalError, resolveDataPath, resolveLogFile, resolveLogLevel } from "@vitest-agent/sdk";
|
|
7
8
|
import { Effect, ManagedRuntime } from "effect";
|
|
@@ -58,7 +59,7 @@ async function main() {
|
|
|
58
59
|
runtime,
|
|
59
60
|
cwd: projectDir,
|
|
60
61
|
currentSessionId: createCurrentSessionIdRef(initialSessionId ?? recoveredContext?.chatId ?? null),
|
|
61
|
-
sessionContext: createSessionContextRef(recoveredContext)
|
|
62
|
+
sessionContext: createSessionContextRef(recoveredContext, () => recoverSessionContextFromSessionEnv({ projectDir }))
|
|
62
63
|
};
|
|
63
64
|
const chatIdResolved = initialSessionId ?? recoveredContext?.chatId ?? null;
|
|
64
65
|
console.error("[vitest-agent-mcp] Starting...");
|
package/context.js
CHANGED
|
@@ -20,14 +20,27 @@ const createCurrentSessionIdRef = (initial = null) => {
|
|
|
20
20
|
/**
|
|
21
21
|
* Creates a new {@link SessionContextRef} with an optional initial value.
|
|
22
22
|
*
|
|
23
|
+
* When a `recover` thunk is supplied, `get()` invokes it lazily while the
|
|
24
|
+
* held value is `null` and caches the first non-null result. This is how
|
|
25
|
+
* a null boot-time context heals at the first tool call that needs it:
|
|
26
|
+
* boot-time env recovery loses both the fresh-launch race (the MCP child
|
|
27
|
+
* can spawn before SessionStart writes `CLAUDE_ENV_FILE`) and the
|
|
28
|
+
* `/reload-plugins` restart (fresh environment, no session exports), but
|
|
29
|
+
* by first-tool-call time the SessionStart hook's session-env file is on
|
|
30
|
+
* disk for the recover thunk to read.
|
|
31
|
+
*
|
|
23
32
|
* @param initial - the starting session context, or `null` when not yet recovered
|
|
33
|
+
* @param recover - optional call-time recovery attempted by `get()` while the value is `null`
|
|
24
34
|
* @returns a mutable ref holding the current session context
|
|
25
35
|
* @public
|
|
26
36
|
*/
|
|
27
|
-
const createSessionContextRef = (initial = null) => {
|
|
37
|
+
const createSessionContextRef = (initial = null, recover) => {
|
|
28
38
|
let value = initial;
|
|
29
39
|
return {
|
|
30
|
-
get: () =>
|
|
40
|
+
get: () => {
|
|
41
|
+
if (value === null && recover !== void 0) value = recover();
|
|
42
|
+
return value;
|
|
43
|
+
},
|
|
31
44
|
set: (ctx) => {
|
|
32
45
|
value = ctx;
|
|
33
46
|
}
|
package/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { DataReader, DataStore, OutputRenderer, ProjectDiscovery } from "@vitest
|
|
|
2
2
|
import { Layer, LogLevel, ManagedRuntime } from "effect";
|
|
3
3
|
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
4
4
|
import * as SqliteMigrator from "@effect/sql-sqlite-node/SqliteMigrator";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
6
|
//#region src/context.d.ts
|
|
6
7
|
/**
|
|
7
8
|
* Mutable holder for the MCP server's currently-associated host chat
|
|
@@ -58,11 +59,21 @@ interface SessionContextRef {
|
|
|
58
59
|
/**
|
|
59
60
|
* Creates a new {@link SessionContextRef} with an optional initial value.
|
|
60
61
|
*
|
|
62
|
+
* When a `recover` thunk is supplied, `get()` invokes it lazily while the
|
|
63
|
+
* held value is `null` and caches the first non-null result. This is how
|
|
64
|
+
* a null boot-time context heals at the first tool call that needs it:
|
|
65
|
+
* boot-time env recovery loses both the fresh-launch race (the MCP child
|
|
66
|
+
* can spawn before SessionStart writes `CLAUDE_ENV_FILE`) and the
|
|
67
|
+
* `/reload-plugins` restart (fresh environment, no session exports), but
|
|
68
|
+
* by first-tool-call time the SessionStart hook's session-env file is on
|
|
69
|
+
* disk for the recover thunk to read.
|
|
70
|
+
*
|
|
61
71
|
* @param initial - the starting session context, or `null` when not yet recovered
|
|
72
|
+
* @param recover - optional call-time recovery attempted by `get()` while the value is `null`
|
|
62
73
|
* @returns a mutable ref holding the current session context
|
|
63
74
|
* @public
|
|
64
75
|
*/
|
|
65
|
-
declare const createSessionContextRef: (initial?: SessionContext | null) => SessionContextRef;
|
|
76
|
+
declare const createSessionContextRef: (initial?: SessionContext | null, recover?: () => SessionContext | null) => SessionContextRef;
|
|
66
77
|
/**
|
|
67
78
|
* tRPC context carrying a ManagedRuntime for Effect service access.
|
|
68
79
|
*
|
|
@@ -1410,7 +1421,8 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
1410
1421
|
hypothesis: import("@trpc/server").TRPCMutationProcedure<{
|
|
1411
1422
|
input: {
|
|
1412
1423
|
readonly action: "record";
|
|
1413
|
-
readonly
|
|
1424
|
+
readonly tddTaskId?: string | number | undefined;
|
|
1425
|
+
readonly sessionId?: string | number | undefined;
|
|
1414
1426
|
readonly content: string;
|
|
1415
1427
|
readonly createdTurnId?: number | undefined;
|
|
1416
1428
|
readonly citedTestErrorId?: number | undefined;
|
|
@@ -1530,18 +1542,58 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
|
|
|
1530
1542
|
}>>;
|
|
1531
1543
|
//#endregion
|
|
1532
1544
|
//#region src/server.d.ts
|
|
1545
|
+
/**
|
|
1546
|
+
* Builds the fully-registered MCP server without connecting a transport.
|
|
1547
|
+
*
|
|
1548
|
+
* Constructs the MCP server instance, registers all tRPC-backed tools
|
|
1549
|
+
* (wired through `ctx.runtime`), and calls `registerAllPrompts`. Split
|
|
1550
|
+
* from {@link startMcpServer} so tests can connect the identical server
|
|
1551
|
+
* to an in-memory transport and assert the *served* tool schemas — the
|
|
1552
|
+
* MCP-SDK-side registrations here are hand-synced with the tRPC inputs
|
|
1553
|
+
* in `tools/`, and a missed sync is invisible to router-level tests.
|
|
1554
|
+
*
|
|
1555
|
+
* @param ctx - the MCP context carrying the shared ManagedRuntime and session refs
|
|
1556
|
+
* @public
|
|
1557
|
+
*/
|
|
1558
|
+
declare function buildMcpServer(ctx: McpContext): McpServer;
|
|
1533
1559
|
/**
|
|
1534
1560
|
* Starts the MCP server over stdio, registering all tools and prompts.
|
|
1535
1561
|
*
|
|
1536
|
-
*
|
|
1537
|
-
* `
|
|
1538
|
-
* a `StdioServerTransport`. Returns when the transport disconnects.
|
|
1562
|
+
* Builds the server via {@link buildMcpServer}, then connects a
|
|
1563
|
+
* `StdioServerTransport`. Returns when the transport disconnects.
|
|
1539
1564
|
*
|
|
1540
1565
|
* @param ctx - the MCP context carrying the shared ManagedRuntime and session refs
|
|
1541
1566
|
* @public
|
|
1542
1567
|
*/
|
|
1543
1568
|
declare function startMcpServer(ctx: McpContext): Promise<void>;
|
|
1544
1569
|
//#endregion
|
|
1570
|
+
//#region src/session-env.d.ts
|
|
1571
|
+
/**
|
|
1572
|
+
* Parse `export KEY=value` lines from a session-env hook file into a
|
|
1573
|
+
* plain record. Non-export lines are ignored.
|
|
1574
|
+
*
|
|
1575
|
+
* @param content - the raw text of a session-env hook file
|
|
1576
|
+
* @returns a record of export names to unquoted values
|
|
1577
|
+
* @public
|
|
1578
|
+
*/
|
|
1579
|
+
declare const parseSessionEnvExports: (content: string) => Record<string, string>;
|
|
1580
|
+
/**
|
|
1581
|
+
* Recover a {@link SessionContext} from the newest session-env hook file
|
|
1582
|
+
* whose `VITEST_AGENT_PROJECT_DIR` matches `projectDir`.
|
|
1583
|
+
*
|
|
1584
|
+
* Returns `null` when the session-env root is missing, unreadable, or no
|
|
1585
|
+
* session dir matches the project. Never throws — recovery is best-effort
|
|
1586
|
+
* and callers fall back to their existing null-context behavior.
|
|
1587
|
+
*
|
|
1588
|
+
* @param opts - `projectDir` to match against; `sessionEnvRoot` overrides
|
|
1589
|
+
* the default `~/.claude/session-env` (tests)
|
|
1590
|
+
* @public
|
|
1591
|
+
*/
|
|
1592
|
+
declare const recoverSessionContextFromSessionEnv: (opts: {
|
|
1593
|
+
readonly projectDir: string;
|
|
1594
|
+
readonly sessionEnvRoot?: string;
|
|
1595
|
+
}) => SessionContext | null;
|
|
1596
|
+
//#endregion
|
|
1545
1597
|
//#region src/tools/_tdd-error-envelope.d.ts
|
|
1546
1598
|
/**
|
|
1547
1599
|
* Suggested recovery action attached to a TDD error envelope.
|
|
@@ -1584,5 +1636,5 @@ interface TddErrorEnvelope {
|
|
|
1584
1636
|
*/
|
|
1585
1637
|
declare const CURRENT_MCP_VERSION: string;
|
|
1586
1638
|
//#endregion
|
|
1587
|
-
export { CURRENT_MCP_VERSION, type CurrentSessionIdRef, type McpContext, McpLive, type Remediation, type SessionContext, type SessionContextRef, type TddErrorEnvelope, appRouter, createCallerFactory, createCurrentSessionIdRef, createSessionContextRef, startMcpServer };
|
|
1639
|
+
export { CURRENT_MCP_VERSION, type CurrentSessionIdRef, type McpContext, McpLive, type Remediation, type SessionContext, type SessionContextRef, type TddErrorEnvelope, appRouter, buildMcpServer, createCallerFactory, createCurrentSessionIdRef, createSessionContextRef, parseSessionEnvExports, recoverSessionContextFromSessionEnv, startMcpServer };
|
|
1588
1640
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createCallerFactory, createCurrentSessionIdRef, createSessionContextRef } from "./context.js";
|
|
2
2
|
import { McpLive } from "./layers/McpLive.js";
|
|
3
3
|
import { appRouter } from "./router.js";
|
|
4
|
-
import { startMcpServer } from "./server.js";
|
|
4
|
+
import { buildMcpServer, startMcpServer } from "./server.js";
|
|
5
|
+
import { parseSessionEnvExports, recoverSessionContextFromSessionEnv } from "./session-env.js";
|
|
5
6
|
|
|
6
7
|
//#region src/index.ts
|
|
7
8
|
/**
|
|
@@ -11,7 +12,7 @@ import { startMcpServer } from "./server.js";
|
|
|
11
12
|
*
|
|
12
13
|
* @public
|
|
13
14
|
*/
|
|
14
|
-
const CURRENT_MCP_VERSION = "2.0.
|
|
15
|
+
const CURRENT_MCP_VERSION = "2.0.6";
|
|
15
16
|
|
|
16
17
|
//#endregion
|
|
17
|
-
export { CURRENT_MCP_VERSION, McpLive, appRouter, createCallerFactory, createCurrentSessionIdRef, createSessionContextRef, startMcpServer };
|
|
18
|
+
export { CURRENT_MCP_VERSION, McpLive, appRouter, buildMcpServer, createCallerFactory, createCurrentSessionIdRef, createSessionContextRef, parseSessionEnvExports, recoverSessionContextFromSessionEnv, startMcpServer };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vitest-agent/mcp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Model Context Protocol server for vitest-agent. Exposes 53 tools for agent access to test data, TDD lifecycle, and session management.",
|
|
6
6
|
"keywords": [
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"@effect/sql-sqlite-node": "4.0.0-beta.99",
|
|
43
43
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
44
44
|
"@trpc/server": "^11.18.0",
|
|
45
|
-
"@vitest-agent/sdk": "2.0.
|
|
45
|
+
"@vitest-agent/sdk": "2.0.6",
|
|
46
46
|
"effect": "4.0.0-beta.99",
|
|
47
47
|
"zod": "^4.4.3"
|
|
48
48
|
},
|
package/server.js
CHANGED
|
@@ -122,16 +122,19 @@ function structuredJsonResult(value) {
|
|
|
122
122
|
return structuredResult(JSON.stringify(value, null, 2), value);
|
|
123
123
|
}
|
|
124
124
|
/**
|
|
125
|
-
*
|
|
125
|
+
* Builds the fully-registered MCP server without connecting a transport.
|
|
126
126
|
*
|
|
127
|
-
* Constructs the MCP server instance, registers all tRPC-backed tools
|
|
128
|
-
* `ctx.runtime`), calls `registerAllPrompts
|
|
129
|
-
*
|
|
127
|
+
* Constructs the MCP server instance, registers all tRPC-backed tools
|
|
128
|
+
* (wired through `ctx.runtime`), and calls `registerAllPrompts`. Split
|
|
129
|
+
* from {@link startMcpServer} so tests can connect the identical server
|
|
130
|
+
* to an in-memory transport and assert the *served* tool schemas — the
|
|
131
|
+
* MCP-SDK-side registrations here are hand-synced with the tRPC inputs
|
|
132
|
+
* in `tools/`, and a missed sync is invisible to router-level tests.
|
|
130
133
|
*
|
|
131
134
|
* @param ctx - the MCP context carrying the shared ManagedRuntime and session refs
|
|
132
135
|
* @public
|
|
133
136
|
*/
|
|
134
|
-
|
|
137
|
+
function buildMcpServer(ctx) {
|
|
135
138
|
const server = new McpServer({
|
|
136
139
|
name: "vitest-agent",
|
|
137
140
|
version: "0.1.0"
|
|
@@ -701,14 +704,15 @@ async function startMcpServer(ctx) {
|
|
|
701
704
|
return structuredResult(Schema.decodeSync(TddArtifactListAsMarkdown)(data), data);
|
|
702
705
|
});
|
|
703
706
|
server.registerTool("hypothesis", {
|
|
704
|
-
description: "Use to manage debugging hypotheses, with a CRUD action discriminator: action='record' (
|
|
707
|
+
description: "Use to manage debugging hypotheses, with a CRUD action discriminator: action='record' (content, tddTaskId?, optional citation ids) writes a hypothesis — the binding session is resolved server-side from the recovered host context (active TDD subagent, else main session); pass tddTaskId (returned by tdd_task action='start') to bind deterministically to that task's session, and do not pass sessionId when recording; action='validate' (id, outcome, validatedAt) records a validation outcome; action='list' (sessionId?, outcome?, limit?) returns matching hypotheses as markdown.",
|
|
705
708
|
inputSchema: {
|
|
706
709
|
action: z.enum([
|
|
707
710
|
"record",
|
|
708
711
|
"validate",
|
|
709
712
|
"list"
|
|
710
713
|
]).describe("CRUD discriminator"),
|
|
711
|
-
sessionId: z.optional(z.coerce.number()).describe("
|
|
714
|
+
sessionId: z.optional(z.coerce.number()).describe("list: filter by session id. record: dev/test fallback only — ignored when host context is recovered; never pass a tddTaskId value here"),
|
|
715
|
+
tddTaskId: z.optional(z.coerce.number()).describe("record: tdd task id returned by tdd_task action='start' — binds the hypothesis to that task's session deterministically"),
|
|
712
716
|
content: z.optional(z.string()).describe("Hypothesis content (action=record)"),
|
|
713
717
|
createdTurnId: z.optional(z.coerce.number()),
|
|
714
718
|
citedTestErrorId: z.optional(z.coerce.number()),
|
|
@@ -728,8 +732,9 @@ async function startMcpServer(ctx) {
|
|
|
728
732
|
}, async (args) => {
|
|
729
733
|
if (args.action === "record") return structuredJsonResult(await caller.hypothesis({
|
|
730
734
|
action: "record",
|
|
731
|
-
sessionId: args.sessionId,
|
|
732
735
|
content: args.content,
|
|
736
|
+
...args.tddTaskId !== void 0 && { tddTaskId: args.tddTaskId },
|
|
737
|
+
...args.sessionId !== void 0 && { sessionId: args.sessionId },
|
|
733
738
|
...args.createdTurnId !== void 0 && { createdTurnId: args.createdTurnId },
|
|
734
739
|
...args.citedTestErrorId !== void 0 && { citedTestErrorId: args.citedTestErrorId },
|
|
735
740
|
...args.citedStackFrameId !== void 0 && { citedStackFrameId: args.citedStackFrameId }
|
|
@@ -828,9 +833,22 @@ async function startMcpServer(ctx) {
|
|
|
828
833
|
return structuredResult(data.message, data);
|
|
829
834
|
});
|
|
830
835
|
registerAllPrompts(server);
|
|
836
|
+
return server;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Starts the MCP server over stdio, registering all tools and prompts.
|
|
840
|
+
*
|
|
841
|
+
* Builds the server via {@link buildMcpServer}, then connects a
|
|
842
|
+
* `StdioServerTransport`. Returns when the transport disconnects.
|
|
843
|
+
*
|
|
844
|
+
* @param ctx - the MCP context carrying the shared ManagedRuntime and session refs
|
|
845
|
+
* @public
|
|
846
|
+
*/
|
|
847
|
+
async function startMcpServer(ctx) {
|
|
848
|
+
const server = buildMcpServer(ctx);
|
|
831
849
|
const transport = new StdioServerTransport();
|
|
832
850
|
await server.connect(transport);
|
|
833
851
|
}
|
|
834
852
|
|
|
835
853
|
//#endregion
|
|
836
|
-
export { startMcpServer };
|
|
854
|
+
export { buildMcpServer, startMcpServer };
|
package/session-env.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
//#region src/session-env.ts
|
|
6
|
+
/**
|
|
7
|
+
* Call-time SessionContext recovery from the per-session env files the
|
|
8
|
+
* plugin's SessionStart hook writes to `~/.claude/session-env/`.
|
|
9
|
+
*
|
|
10
|
+
* The boot-time env recovery (`sessionContextFromEnv`) depends on Claude
|
|
11
|
+
* Code auto-sourcing `CLAUDE_ENV_FILE` into the MCP child — which loses
|
|
12
|
+
* two races:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Boot race.** On a fresh Claude Code launch the MCP child can spawn
|
|
15
|
+
* before the SessionStart hook has written `CLAUDE_ENV_FILE`, so the
|
|
16
|
+
* child's `process.env` never carries the canonical UUIDs (observed
|
|
17
|
+
* live: MCP spawn at 00:41:50, env file written 00:41:51).
|
|
18
|
+
* 2. **`/reload-plugins`.** A plugin reload restarts the MCP server
|
|
19
|
+
* mid-session with a fresh environment that has no session exports.
|
|
20
|
+
*
|
|
21
|
+
* In both cases the SessionStart hook has (or will have) written the same
|
|
22
|
+
* exports to a second, known-name surface:
|
|
23
|
+
* `~/.claude/session-env/<chat_id>/vitest-agent-hook.sh`. This module
|
|
24
|
+
* reads that surface directly, so a null boot context can be recovered
|
|
25
|
+
* lazily at the first tool call that needs it.
|
|
26
|
+
*
|
|
27
|
+
* Selection rule: among all session dirs whose exports name this server's
|
|
28
|
+
* `projectDir`, the newest-mtime file wins — the most recently started
|
|
29
|
+
* session for this project. With two live Claude Code windows on the same
|
|
30
|
+
* project this can name the other window's session; that ambiguity is
|
|
31
|
+
* inherent to a per-project (not per-process) surface and is accepted —
|
|
32
|
+
* the pre-existing alternative was no attribution at all.
|
|
33
|
+
*
|
|
34
|
+
* @packageDocumentation
|
|
35
|
+
*/
|
|
36
|
+
const EXPORT_LINE = /^export ([A-Z_][A-Z0-9_]*)=(.*)$/;
|
|
37
|
+
/**
|
|
38
|
+
* Undo the `printf '%q'` quoting the SessionStart hook applies to export
|
|
39
|
+
* values. UUIDs and plain paths arrive bare; values with specials arrive
|
|
40
|
+
* as `$'...'`, `'...'`, `"..."`, or backslash-escaped words.
|
|
41
|
+
*/
|
|
42
|
+
const unquote = (raw) => {
|
|
43
|
+
let v = raw.trim();
|
|
44
|
+
if (v.startsWith("$'") && v.endsWith("'") && v.length >= 3) v = v.slice(2, -1);
|
|
45
|
+
else if (v.startsWith("'") && v.endsWith("'") || v.startsWith("\"") && v.endsWith("\"")) {
|
|
46
|
+
if (v.length >= 2) v = v.slice(1, -1);
|
|
47
|
+
}
|
|
48
|
+
return v.replace(/\\(.)/g, "$1");
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Parse `export KEY=value` lines from a session-env hook file into a
|
|
52
|
+
* plain record. Non-export lines are ignored.
|
|
53
|
+
*
|
|
54
|
+
* @param content - the raw text of a session-env hook file
|
|
55
|
+
* @returns a record of export names to unquoted values
|
|
56
|
+
* @public
|
|
57
|
+
*/
|
|
58
|
+
const parseSessionEnvExports = (content) => {
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const line of content.split("\n")) {
|
|
61
|
+
const m = EXPORT_LINE.exec(line.trim());
|
|
62
|
+
if (m?.[1] !== void 0 && m[2] !== void 0) out[m[1]] = unquote(m[2]);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Recover a {@link SessionContext} from the newest session-env hook file
|
|
68
|
+
* whose `VITEST_AGENT_PROJECT_DIR` matches `projectDir`.
|
|
69
|
+
*
|
|
70
|
+
* Returns `null` when the session-env root is missing, unreadable, or no
|
|
71
|
+
* session dir matches the project. Never throws — recovery is best-effort
|
|
72
|
+
* and callers fall back to their existing null-context behavior.
|
|
73
|
+
*
|
|
74
|
+
* @param opts - `projectDir` to match against; `sessionEnvRoot` overrides
|
|
75
|
+
* the default `~/.claude/session-env` (tests)
|
|
76
|
+
* @public
|
|
77
|
+
*/
|
|
78
|
+
const recoverSessionContextFromSessionEnv = (opts) => {
|
|
79
|
+
const root = opts.sessionEnvRoot ?? join(homedir(), ".claude", "session-env");
|
|
80
|
+
const wantDir = resolve(opts.projectDir);
|
|
81
|
+
let entries;
|
|
82
|
+
try {
|
|
83
|
+
entries = readdirSync(root);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
let best = null;
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const file = join(root, entry, "vitest-agent-hook.sh");
|
|
90
|
+
try {
|
|
91
|
+
const st = statSync(file);
|
|
92
|
+
const env = parseSessionEnvExports(readFileSync(file, "utf8"));
|
|
93
|
+
const chatId = env.VITEST_AGENT_CHAT_ID;
|
|
94
|
+
const conversationId = env.VITEST_AGENT_CONVERSATION_ID;
|
|
95
|
+
const mainAgentId = env.VITEST_AGENT_MAIN_AGENT_ID ?? env.VITEST_AGENT_AGENT_ID;
|
|
96
|
+
const fileProjectDir = env.VITEST_AGENT_PROJECT_DIR;
|
|
97
|
+
if (chatId === void 0 || chatId.length === 0 || conversationId === void 0 || conversationId.length === 0 || mainAgentId === void 0 || mainAgentId.length === 0 || fileProjectDir === void 0 || resolve(fileProjectDir) !== wantDir) continue;
|
|
98
|
+
if (best === null || st.mtimeMs > best.mtimeMs) best = {
|
|
99
|
+
mtimeMs: st.mtimeMs,
|
|
100
|
+
ctx: {
|
|
101
|
+
chatId,
|
|
102
|
+
conversationId,
|
|
103
|
+
mainAgentId
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
return best === null ? null : best.ctx;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
//#endregion
|
|
112
|
+
export { parseSessionEnvExports, recoverSessionContextFromSessionEnv };
|
package/tools/hypothesis.js
CHANGED
|
@@ -57,7 +57,8 @@ const formatHypothesisListMarkdown = (data) => {
|
|
|
57
57
|
};
|
|
58
58
|
const RecordVariant = Schema.Struct({
|
|
59
59
|
action: Schema.Literal("record"),
|
|
60
|
-
|
|
60
|
+
tddTaskId: Schema.optional(Schema.Union([Schema.Number, Schema.FiniteFromString])),
|
|
61
|
+
sessionId: Schema.optional(Schema.Union([Schema.Number, Schema.FiniteFromString])),
|
|
61
62
|
content: Schema.String,
|
|
62
63
|
createdTurnId: Schema.optional(Schema.Number),
|
|
63
64
|
citedTestErrorId: Schema.optional(Schema.Number),
|
|
@@ -95,20 +96,31 @@ const hypothesis = idempotentProcedure.input(Schema.toStandardSchemaV1(Hypothesi
|
|
|
95
96
|
record: (variant) => Effect.gen(function* () {
|
|
96
97
|
const store = yield* DataStore;
|
|
97
98
|
const reader = yield* DataReader;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
99
|
+
let resolvedSessionId;
|
|
100
|
+
if (variant.tddTaskId !== void 0) {
|
|
101
|
+
const bound = yield* reader.getSessionByTddTaskId(variant.tddTaskId);
|
|
102
|
+
if (Option.isNone(bound)) return yield* Effect.fail(new DataStoreError({
|
|
103
|
+
operation: "write",
|
|
104
|
+
table: "hypotheses",
|
|
105
|
+
reason: `unknown tddTaskId ${variant.tddTaskId}: no session found to attribute hypothesis`
|
|
106
|
+
}));
|
|
107
|
+
resolvedSessionId = bound.value.id;
|
|
108
|
+
} else {
|
|
109
|
+
const sc = ctx.sessionContext.get();
|
|
110
|
+
resolvedSessionId = variant.sessionId;
|
|
111
|
+
if (sc !== null) {
|
|
112
|
+
const main = yield* reader.getSessionByChatId(sc.chatId);
|
|
113
|
+
if (Option.isSome(main)) {
|
|
114
|
+
const sub = yield* reader.findActiveSubagentSession(main.value.id);
|
|
115
|
+
resolvedSessionId = Option.isSome(sub) ? sub.value.id : main.value.id;
|
|
116
|
+
}
|
|
105
117
|
}
|
|
118
|
+
if (resolvedSessionId === void 0) return yield* Effect.fail(new DataStoreError({
|
|
119
|
+
operation: "write",
|
|
120
|
+
table: "hypotheses",
|
|
121
|
+
reason: "no recovered session context: pass tddTaskId (the id returned by tdd_task action:start) to bind this hypothesis to your task's session — do not retry with a raw sessionId, and never pass tddTaskId under a sessionId key"
|
|
122
|
+
}));
|
|
106
123
|
}
|
|
107
|
-
if (resolvedSessionId === void 0) return yield* Effect.fail(new DataStoreError({
|
|
108
|
-
operation: "write",
|
|
109
|
-
table: "hypotheses",
|
|
110
|
-
reason: "no recovered session context and no sessionId supplied to attribute hypothesis"
|
|
111
|
-
}));
|
|
112
124
|
return {
|
|
113
125
|
action: "record",
|
|
114
126
|
id: yield* store.writeHypothesis({
|