From 6727c0337f621897f47840547582e42a62c2454d Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Mon, 13 Jul 2026 15:34:01 -0700 Subject: [PATCH] Tweak metadata --- README.md | 30 ++++++++--------- bundle/stop.js | 36 +++++++++++++++------ src/config.ts | 7 ++-- src/conversation-steps.ts | 13 ++++++-- src/langsmith.ts | 27 ++++++++++++---- src/metadata.ts | 14 +++++--- src/system-prompt.ts | 8 ++--- test/config.test.ts | 4 ++- test/conversation-steps.test.ts | 16 +++++++-- test/metadata.test.ts | 57 +++++++++++++++++++++++++++------ test/normalize.test.ts | 4 ++- test/system-prompt.test.ts | 16 ++++++--- 12 files changed, 167 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 84b9fe4..5dbbe64 100644 --- a/README.md +++ b/README.md @@ -73,21 +73,21 @@ Config resolves in this order (later overrides earlier): defaults → `~/.cursor Every `LANGSMITH_CURSOR_*` variable also accepts the `LANGSMITH_*` form (the `LANGSMITH_CURSOR_*` name wins when both are set). -| Environment variable | Config key | Description | Default | -| --------------------------------- | ---------------- | --------------------------------------------------------------------- | --------------------------------- | -| `TRACE_TO_LANGSMITH` | `enabled` | Master switch — tracing runs only when truthy. | `false` | -| `LANGSMITH_CURSOR_API_KEY` | `api_key` | LangSmith API key. | — | -| `LANGSMITH_CURSOR_ENDPOINT` | `api_url` | LangSmith API base URL. | `https://api.smith.langchain.com` | -| `LANGSMITH_CURSOR_PROJECT` | `project` | Target tracing project. | `cursor` | -| `LANGSMITH_CURSOR_METADATA` | `metadata` | Extra metadata attached to every run (JSON object). | — | -| `LANGSMITH_CURSOR_RUNS_ENDPOINTS` | `replicas` | Additional replica destinations (JSON array). | — | -| `LANGSMITH_CURSOR_ATTACHMENTS` | `attachments` | Enrich turns with image/file attachment bytes from Cursor's DB. | `true` | -| `LANGSMITH_CURSOR_DB_PATH` | `cursor_db_path` | Override the Cursor `state.vscdb` path used for attachments. | platform default | -| `LANGSMITH_CURSOR_REDACT` | `redact` | Redact detected secrets from traced data before upload. | `true` | +| Environment variable | Config key | Description | Default | +| --------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| `TRACE_TO_LANGSMITH` | `enabled` | Master switch — tracing runs only when truthy. | `false` | +| `LANGSMITH_CURSOR_API_KEY` | `api_key` | LangSmith API key. | — | +| `LANGSMITH_CURSOR_ENDPOINT` | `api_url` | LangSmith API base URL. | `https://api.smith.langchain.com` | +| `LANGSMITH_CURSOR_PROJECT` | `project` | Target tracing project. | `cursor` | +| `LANGSMITH_CURSOR_METADATA` | `metadata` | Extra metadata attached to every run (JSON object). | — | +| `LANGSMITH_CURSOR_RUNS_ENDPOINTS` | `replicas` | Additional replica destinations (JSON array). | — | +| `LANGSMITH_CURSOR_ATTACHMENTS` | `attachments` | Enrich turns with image/file attachment bytes from Cursor's DB. | `true` | +| `LANGSMITH_CURSOR_DB_PATH` | `cursor_db_path` | Override the Cursor `state.vscdb` path used for attachments. | platform default | +| `LANGSMITH_CURSOR_REDACT` | `redact` | Redact detected secrets from traced data before upload. | `true` | | `LANGSMITH_CURSOR_REDACT_EXTRA` | — | Extra redaction rules: JSON array of `{ pattern, replace }`; each `pattern` is case-sensitive and applied with the `g` flag. | — | -| `LANGSMITH_CURSOR_DEBUG` | — | Verbose hook logging. | `false` | -| `LANGSMITH_CURSOR_STATE_FILE` | — | Override the on-disk event-buffer state file (no `LANGSMITH_*` form). | `~/.cursor/langsmith-state.json` | -| `LANGSMITH_CURSOR_LOG_FILE` | — | Override the hook log file (no `LANGSMITH_*` form). | `~/.cursor/langsmith-hook.log` | +| `LANGSMITH_CURSOR_DEBUG` | — | Verbose hook logging. | `false` | +| `LANGSMITH_CURSOR_STATE_FILE` | — | Override the on-disk event-buffer state file (no `LANGSMITH_*` form). | `~/.cursor/langsmith-state.json` | +| `LANGSMITH_CURSOR_LOG_FILE` | — | Override the hook log file (no `LANGSMITH_*` form). | `~/.cursor/langsmith-hook.log` | Tracing only runs when `enabled` (or `TRACE_TO_LANGSMITH=true`) **and** an API key (or replicas) is set. @@ -110,7 +110,7 @@ We don't compute cost locally. Instead, Cursor's model labels (e.g. `claude-4.6- Every run carries the shared [`coding-agent-v1`](https://github.com/langchain-ai/langsmith) coding-agent metadata contract on `run.extra.metadata`, built by one helper (`src/metadata.ts`) and propagated to child runs. This lets traces from any coding agent (Claude Code, Codex, Cursor, …) be identified, grouped, and attributed with the same stable keys. -**Always present** (every run): `ls_agent_kind` (`"coding_agent"`), `ls_integration` (`"cursor"`), `ls_agent_runtime` (`"Cursor"`), `ls_trace_schema_version` (`"coding-agent-v1"`), `thread_id` (= `conversation_id`). +**Always present** (every run): `ls_agent_purpose` (`"coding"`), `ls_agent_type` (`"root"` or `"subagent"` based on the owning agent), `ls_integration` (`"cursor"`), `ls_agent_runtime` (`"Cursor"`), `ls_trace_schema_version` (`"coding-agent-v1"`), `thread_id` (= `conversation_id`). **Present where known** (every run): `ls_integration_version` (plugin version, build-time injected), `ls_agent_runtime_version` (Cursor's `cursor_version`), `turn_id` (= `generation_id`), `turn_number`, `repository_url` / `repository_provider` / `repository_name`, `git_branch`, `git_commit_sha`, `cwd`. diff --git a/bundle/stop.js b/bundle/stop.js index e27017e..b7d14b0 100755 --- a/bundle/stop.js +++ b/bundle/stop.js @@ -12810,15 +12810,16 @@ function createSecretAnonymizer(options) { } // dist/metadata.js -var LS_AGENT_KIND = "coding_agent"; +var LS_AGENT_PURPOSE = "coding"; var LS_INTEGRATION = "cursor"; var LS_AGENT_RUNTIME = "Cursor"; var LS_TRACE_SCHEMA_VERSION = "coding-agent-v1"; function codingAgentMetadata(opts) { - const { threadId, base, turnId, turnNumber, runtimeVersion, approvalPolicy, subagentId, subagentType, clearSubagent, toolName, runName, runSpecific } = opts; + const { agentType, threadId, base, turnId, turnNumber, runtimeVersion, approvalPolicy, subagentId, subagentType, clearSubagent, toolName, runName, runSpecific } = opts; const meta = { // Identity & grouping — always present. - ls_agent_kind: LS_AGENT_KIND, + ls_agent_purpose: LS_AGENT_PURPOSE, + ls_agent_type: agentType, ls_integration: LS_INTEGRATION, ls_agent_runtime: LS_AGENT_RUNTIME, ls_trace_schema_version: LS_TRACE_SCHEMA_VERSION, @@ -13300,7 +13301,10 @@ function decodeStep(buf) { } const assistant = firstBytes(buf, STEP_ASSISTANT_FIELD); if (assistant) { - return { kind: "assistant", text: firstBytes(assistant, MESSAGE_TEXT_FIELD)?.toString("utf-8") }; + return { + kind: "assistant", + text: firstBytes(assistant, MESSAGE_TEXT_FIELD)?.toString("utf-8") + }; } return void 0; } @@ -13489,6 +13493,7 @@ async function buildTurnRuns(options) { throw new Error("LangSmith client not initialized \u2014 call initTracing() first"); } const ctx = { + agentType: "root", threadId: conversationId, base: { ...customMetadata, ...userEmail ? { user_email: userEmail } : {} }, turnId: buffer.generation_id, @@ -13710,6 +13715,7 @@ async function postSubagentRun(sub, parent, ctx) { ls_model_name: subModel.ls_model_name, ls_invocation_params: { model: subModel.ls_model_name } }; + const subagentCtx = { ...ctx, agentType: "subagent" }; const subagentRun = parent.createChild({ name: runName, run_type: "chain", @@ -13727,7 +13733,7 @@ async function postSubagentRun(sub, parent, ctx) { end_time: endMs, extra: { metadata: codingAgentMetadata({ - ...ctx, + ...subagentCtx, subagentId: sub.subagent_id, subagentType: sub.subagent_type, runSpecific: { @@ -13758,7 +13764,11 @@ async function postSubagentRun(sub, parent, ctx) { start_time: startMs, end_time: endMs, extra: { - metadata: codingAgentMetadata({ ...ctx, clearSubagent: true, runSpecific: { ...llmMeta } }) + metadata: codingAgentMetadata({ + ...subagentCtx, + clearSubagent: true, + runSpecific: { ...llmMeta } + }) } }); await llmRun.postRun(); @@ -13775,12 +13785,16 @@ async function postSubagentRun(sub, parent, ctx) { start_time: startMs, end_time: Math.max(startMs, firstCallStart), extra: { - metadata: codingAgentMetadata({ ...ctx, clearSubagent: true, runSpecific: { ...llmMeta } }) + metadata: codingAgentMetadata({ + ...subagentCtx, + clearSubagent: true, + runSpecific: { ...llmMeta } + }) } }); await decideRun.postRun(); for (const tool of tools) - await postToolRun(tool, subagentRun, ctx, true); + await postToolRun(tool, subagentRun, subagentCtx, true); const answerRun = subagentRun.createChild({ name: llmName, run_type: "llm", @@ -13795,7 +13809,11 @@ async function postSubagentRun(sub, parent, ctx) { start_time: lastCallEnd, end_time: endMs, extra: { - metadata: codingAgentMetadata({ ...ctx, clearSubagent: true, runSpecific: { ...llmMeta } }) + metadata: codingAgentMetadata({ + ...subagentCtx, + clearSubagent: true, + runSpecific: { ...llmMeta } + }) } }); await answerRun.postRun(); diff --git a/src/config.ts b/src/config.ts index 1689829..99be773 100644 --- a/src/config.ts +++ b/src/config.ts @@ -79,7 +79,9 @@ function parseJson>(value: unknown): T | undefined { function isRedactRule(rule: unknown): rule is StringNodeRule { if (typeof rule !== "object" || rule === null) return false; const r = rule as Record; - return typeof r.pattern === "string" && (r.replace === undefined || typeof r.replace === "string"); + return ( + typeof r.pattern === "string" && (r.replace === undefined || typeof r.replace === "string") + ); } /** Parse a JSON array of { pattern, replace }; invalid rules are logged and skipped. */ @@ -256,8 +258,7 @@ export function loadConfig(options?: { cwd?: string }): Config { true; const cursorDbPath = getEnv("DB_PATH") ?? localFile?.cursor_db_path ?? globalFile?.cursor_db_path; - const redact = - parseBoolean(getEnv("REDACT")) ?? localFile?.redact ?? globalFile?.redact ?? true; + const redact = parseBoolean(getEnv("REDACT")) ?? localFile?.redact ?? globalFile?.redact ?? true; const redactExtraRules = parseRedactExtraRules(getEnv("REDACT_EXTRA")); const stateFilePath = diff --git a/src/conversation-steps.ts b/src/conversation-steps.ts index 1768d3d..e8b361a 100644 --- a/src/conversation-steps.ts +++ b/src/conversation-steps.ts @@ -192,7 +192,10 @@ export function decodeStep(buf: Buffer): Step | undefined { } const assistant = firstBytes(buf, STEP_ASSISTANT_FIELD); if (assistant) { - return { kind: "assistant", text: firstBytes(assistant, MESSAGE_TEXT_FIELD)?.toString("utf-8") }; + return { + kind: "assistant", + text: firstBytes(assistant, MESSAGE_TEXT_FIELD)?.toString("utf-8"), + }; } return undefined; } @@ -314,7 +317,9 @@ export function resolveTurnSteps(opts: ResolveTurnStepsOptions): Step[] | undefi for (let i = turnIds.length - 1; i >= 0; i--) { const steps = decodeTurnSteps(reader, turnIds[i]); if (!steps) continue; - const overlap = steps.some((s) => s.kind === "tool" && s.toolUseId && wanted.has(s.toolUseId)); + const overlap = steps.some( + (s) => s.kind === "tool" && s.toolUseId && wanted.has(s.toolUseId), + ); if (overlap) { logger.log( `conversation-steps: recovered ${steps.length} step(s) for ${opts.conversationId}`, @@ -328,7 +333,9 @@ export function resolveTurnSteps(opts: ResolveTurnStepsOptions): Step[] | undefi reader.close(); } } catch (err) { - logger.warn(`conversation-steps: resolution failed for ${opts.conversationId}, skipping (${err})`); + logger.warn( + `conversation-steps: resolution failed for ${opts.conversationId}, skipping (${err})`, + ); return undefined; } } diff --git a/src/langsmith.ts b/src/langsmith.ts index 01386f6..2fb4015 100644 --- a/src/langsmith.ts +++ b/src/langsmith.ts @@ -6,7 +6,7 @@ import type { StringNodeRule } from "langsmith/anonymizer"; import type { TurnBuffer, ToolEvent, SubagentEvent, ContentPart } from "./types.js"; import { buildUsageMetadata, deriveModelInfo } from "./normalize.js"; import { DEFAULT_TAGS, TURN_RUN_NAME } from "./constants.js"; -import { codingAgentMetadata } from "./metadata.js"; +import { codingAgentMetadata, type LSAgentType } from "./metadata.js"; import { groupSteps, type Step } from "./conversation-steps.js"; import * as logger from "./logger.js"; @@ -101,6 +101,7 @@ export interface BuildTurnOptions { /** Per-turn context shared by every run's coding-agent-v1 metadata. */ interface MetaCtx { + agentType: LSAgentType; threadId: string; base?: Record; turnId?: string; @@ -192,6 +193,7 @@ export async function buildTurnRuns(options: BuildTurnOptions): Promise { // coding-agent-v1 context, stamped on the root and propagated to children // via createChild. user_email (per-turn) joins the config base. const ctx: MetaCtx = { + agentType: "root", threadId: conversationId, base: { ...customMetadata, ...(userEmail ? { user_email: userEmail } : {}) }, turnId: buffer.generation_id, @@ -497,6 +499,7 @@ async function postSubagentRun(sub: SubagentEvent, parent: RunTree, ctx: MetaCtx ls_model_name: subModel.ls_model_name, ls_invocation_params: { model: subModel.ls_model_name }, }; + const subagentCtx: MetaCtx = { ...ctx, agentType: "subagent" }; // Subagent = nested chain run (validator runType "subagent"); children clear // ls_subagent_id/type so they don't leak down. @@ -517,7 +520,7 @@ async function postSubagentRun(sub: SubagentEvent, parent: RunTree, ctx: MetaCtx end_time: endMs, extra: { metadata: codingAgentMetadata({ - ...ctx, + ...subagentCtx, subagentId: sub.subagent_id, subagentType: sub.subagent_type, runSpecific: { @@ -554,7 +557,11 @@ async function postSubagentRun(sub: SubagentEvent, parent: RunTree, ctx: MetaCtx start_time: startMs, end_time: endMs, extra: { - metadata: codingAgentMetadata({ ...ctx, clearSubagent: true, runSpecific: { ...llmMeta } }), + metadata: codingAgentMetadata({ + ...subagentCtx, + clearSubagent: true, + runSpecific: { ...llmMeta }, + }), }, }); await llmRun.postRun(); @@ -574,12 +581,16 @@ async function postSubagentRun(sub: SubagentEvent, parent: RunTree, ctx: MetaCtx start_time: startMs, end_time: Math.max(startMs, firstCallStart), extra: { - metadata: codingAgentMetadata({ ...ctx, clearSubagent: true, runSpecific: { ...llmMeta } }), + metadata: codingAgentMetadata({ + ...subagentCtx, + clearSubagent: true, + runSpecific: { ...llmMeta }, + }), }, }); await decideRun.postRun(); - for (const tool of tools) await postToolRun(tool, subagentRun, ctx, true); + for (const tool of tools) await postToolRun(tool, subagentRun, subagentCtx, true); const answerRun = subagentRun.createChild({ name: llmName, @@ -595,7 +606,11 @@ async function postSubagentRun(sub: SubagentEvent, parent: RunTree, ctx: MetaCtx start_time: lastCallEnd, end_time: endMs, extra: { - metadata: codingAgentMetadata({ ...ctx, clearSubagent: true, runSpecific: { ...llmMeta } }), + metadata: codingAgentMetadata({ + ...subagentCtx, + clearSubagent: true, + runSpecific: { ...llmMeta }, + }), }, }); await answerRun.postRun(); diff --git a/src/metadata.ts b/src/metadata.ts index bf4caea..959c3fd 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -5,7 +5,8 @@ // ─── Frozen literals (identity block) ──────────────────────────────────────── -export const LS_AGENT_KIND = "coding_agent"; +export const LS_AGENT_PURPOSE = "coding"; +export type LSAgentType = "root" | "subagent" | "middleware" | "compaction"; export const LS_INTEGRATION = "cursor"; export const LS_AGENT_RUNTIME = "Cursor"; export const LS_TRACE_SCHEMA_VERSION = "coding-agent-v1"; @@ -13,6 +14,9 @@ export const LS_TRACE_SCHEMA_VERSION = "coding-agent-v1"; // ─── Helper input ───────────────────────────────────────────────────────────── export interface CodingAgentMetadataOptions { + /** Role of the run within the coding-agent trace. Required on every run. */ + agentType: LSAgentType; + /** Stable conversation id → `thread_id`. Required on every run. */ threadId: string; @@ -50,10 +54,9 @@ export interface CodingAgentMetadataOptions { * Build the coding-agent-v1 metadata for one run. Merge order (later wins): * identity → dynamic → runSpecific → base. */ -export function codingAgentMetadata( - opts: CodingAgentMetadataOptions, -): Record { +export function codingAgentMetadata(opts: CodingAgentMetadataOptions): Record { const { + agentType, threadId, base, turnId, @@ -70,7 +73,8 @@ export function codingAgentMetadata( const meta: Record = { // Identity & grouping — always present. - ls_agent_kind: LS_AGENT_KIND, + ls_agent_purpose: LS_AGENT_PURPOSE, + ls_agent_type: agentType, ls_integration: LS_INTEGRATION, ls_agent_runtime: LS_AGENT_RUNTIME, ls_trace_schema_version: LS_TRACE_SCHEMA_VERSION, diff --git a/src/system-prompt.ts b/src/system-prompt.ts index 6d91274..e35b955 100644 --- a/src/system-prompt.ts +++ b/src/system-prompt.ts @@ -74,9 +74,7 @@ export function readProtoLenField(buf: Buffer, field: number): Buffer[] { /** Decode a composerData.conversationState value: "~"-prefixed base64, else hex. */ export function decodeConversationStateBlob(raw: unknown): Buffer | undefined { if (typeof raw !== "string" || raw.length === 0) return undefined; - const buf = raw.startsWith("~") - ? Buffer.from(raw.slice(1), "base64") - : Buffer.from(raw, "hex"); + const buf = raw.startsWith("~") ? Buffer.from(raw.slice(1), "base64") : Buffer.from(raw, "hex"); return buf.length > 0 ? buf : undefined; } @@ -125,7 +123,9 @@ function systemPromptFor(reader: BlobReader, conversationId: string): string | u if (!composer) return undefined; const parsed = JSON.parse(composer.toString("utf-8")); - const blob = decodeConversationStateBlob(isRecord(parsed) ? parsed.conversationState : undefined); + const blob = decodeConversationStateBlob( + isRecord(parsed) ? parsed.conversationState : undefined, + ); if (!blob) return undefined; for (const id of readProtoLenField(blob, ROOT_PROMPT_MESSAGES_FIELD)) { diff --git a/test/config.test.ts b/test/config.test.ts index c23f4a9..33dd1d2 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -90,7 +90,9 @@ describe("loadConfig cascade", () => { "LANGSMITH_CURSOR_REDACT_EXTRA", JSON.stringify([{ pattern: "sk-\\w+", replace: "X" }, { pattern: 42 }, { replace: "Y" }]), ); - expect(loadConfig({ cwd: home }).redactExtraRules).toEqual([{ pattern: "sk-\\w+", replace: "X" }]); + expect(loadConfig({ cwd: home }).redactExtraRules).toEqual([ + { pattern: "sk-\\w+", replace: "X" }, + ]); }); it("attaches local_username identity metadata", () => { diff --git a/test/conversation-steps.test.ts b/test/conversation-steps.test.ts index 5bc55f0..b7db93f 100644 --- a/test/conversation-steps.test.ts +++ b/test/conversation-steps.test.ts @@ -30,7 +30,11 @@ function varint(n: number): number[] { return bytes; } function lenField(field: number, value: Buffer): Buffer { - return Buffer.concat([Buffer.from(varint((field << 3) | 2)), Buffer.from(varint(value.length)), value]); + return Buffer.concat([ + Buffer.from(varint((field << 3) | 2)), + Buffer.from(varint(value.length)), + value, + ]); } function varintField(field: number, value: number): Buffer { return Buffer.concat([Buffer.from(varint((field << 3) | 0)), Buffer.from(varint(value))]); @@ -55,7 +59,10 @@ describe("decodeStep", () => { text: "pondering", durationMs: 82, }); - expect(decodeStep(assistantStep("the answer"))).toEqual({ kind: "assistant", text: "the answer" }); + expect(decodeStep(assistantStep("the answer"))).toEqual({ + kind: "assistant", + text: "the answer", + }); expect(decodeStep(toolStep(8, "tool_abc"))).toEqual({ kind: "tool", toolUseId: "tool_abc", @@ -65,7 +72,10 @@ describe("decodeStep", () => { }); it("tolerates unmapped tool fields, unknown steps, and truncated blobs", () => { - expect(decodeStep(toolStep(999, "tool_xyz"))).toMatchObject({ toolField: 999, toolName: undefined }); + expect(decodeStep(toolStep(999, "tool_xyz"))).toMatchObject({ + toolField: 999, + toolName: undefined, + }); expect(decodeStep(varintField(7, 1))).toBeUndefined(); expect(scanFields(Buffer.from([(1 << 3) | 2, 50, 0x01, 0x02]))).toEqual([]); }); diff --git a/test/metadata.test.ts b/test/metadata.test.ts index 6889c05..0279cc4 100644 --- a/test/metadata.test.ts +++ b/test/metadata.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; import type { Run } from "langsmith"; -import { codingAgentMetadata } from "../src/metadata.js"; +import { codingAgentMetadata, type LSAgentType } from "../src/metadata.js"; import { replayHookLog } from "./utils/replay.js"; import { mockClient } from "./utils/mock_client.js"; import { getAssumedTreeFromCalls } from "./utils/tree.js"; @@ -17,16 +17,27 @@ function meta(run: Run): Record { describe("codingAgentMetadata helper", () => { it("always emits the identity block with the frozen cursor literals", () => { - const m = codingAgentMetadata({ threadId: "conv-1" }); - expect(m.ls_agent_kind).toBe("coding_agent"); + const m = codingAgentMetadata({ agentType: "root", threadId: "conv-1" }); + expect(m.ls_agent_purpose).toBe("coding"); + expect(m.ls_agent_type).toBe("root"); + expect(m.ls_agent_kind).toBeUndefined(); expect(m.ls_integration).toBe("cursor"); expect(m.ls_agent_runtime).toBe("Cursor"); expect(m.ls_trace_schema_version).toBe("coding-agent-v1"); expect(m.thread_id).toBe("conv-1"); }); + it.each(["root", "subagent", "middleware", "compaction"])( + "supports the %s agent type", + (agentType) => { + const m = codingAgentMetadata({ agentType, threadId: "c" }); + expect(m.ls_agent_type).toBe(agentType); + }, + ); + it("emits turn + runtime version keys when known, omits when not", () => { const m = codingAgentMetadata({ + agentType: "root", threadId: "c", turnId: "gen-9", turnNumber: 3, @@ -36,18 +47,27 @@ describe("codingAgentMetadata helper", () => { expect(m.turn_number).toBe(3); expect(m.ls_agent_runtime_version).toBe("3.7.19"); - const bare = codingAgentMetadata({ threadId: "c" }); + const bare = codingAgentMetadata({ agentType: "root", threadId: "c" }); expect("turn_id" in bare).toBe(false); expect("turn_number" in bare).toBe(false); expect("ls_agent_runtime_version" in bare).toBe(false); }); it("emits subagent identity keys, and clearSubagent nulls them out (undefined)", () => { - const sub = codingAgentMetadata({ threadId: "c", subagentId: "s1", subagentType: "explore" }); + const sub = codingAgentMetadata({ + agentType: "subagent", + threadId: "c", + subagentId: "s1", + subagentType: "explore", + }); expect(sub.ls_subagent_id).toBe("s1"); expect(sub.ls_subagent_type).toBe("explore"); - const child = codingAgentMetadata({ threadId: "c", clearSubagent: true }); + const child = codingAgentMetadata({ + agentType: "subagent", + threadId: "c", + clearSubagent: true, + }); // Present-but-undefined → dropped on JSON serialization, never reaches the server. expect(child.ls_subagent_id).toBeUndefined(); expect(child.ls_subagent_type).toBeUndefined(); @@ -56,15 +76,21 @@ describe("codingAgentMetadata helper", () => { it("emits ls_tool_name only when the native tool name differs from the run name", () => { expect( - codingAgentMetadata({ threadId: "c", toolName: "Bash", runName: "Bash" }).ls_tool_name, + codingAgentMetadata({ agentType: "root", threadId: "c", toolName: "Bash", runName: "Bash" }) + .ls_tool_name, ).toBeUndefined(); expect( - codingAgentMetadata({ threadId: "c", toolName: "Task", runName: "Agent" }).ls_tool_name, + codingAgentMetadata({ agentType: "root", threadId: "c", toolName: "Task", runName: "Agent" }) + .ls_tool_name, ).toBe("Task"); }); it("lets base (user config) win on key collision", () => { - const m = codingAgentMetadata({ threadId: "c", base: { thread_id: "override", extra: 1 } }); + const m = codingAgentMetadata({ + agentType: "root", + threadId: "c", + base: { thread_id: "override", extra: 1 }, + }); expect(m.thread_id).toBe("override"); expect(m.extra).toBe(1); }); @@ -74,7 +100,7 @@ describe("codingAgentMetadata helper", () => { // Mirrors validate-thread.mjs's classify + required-key/leak rules in-process. const ALWAYS = [ - ["ls_agent_kind", "coding_agent"], + ["ls_agent_purpose", "coding"], ["ls_integration", "cursor"], ["ls_agent_runtime", "Cursor"], ["ls_trace_schema_version", "coding-agent-v1"], @@ -118,6 +144,7 @@ describe("coding-agent-v1 contract on the produced run tree", () => { const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); const runs = Object.values(tree.data); + const byId = new Map(runs.map((run) => [run.id, run])); expect(runs.length).toBeGreaterThan(3); // Serialize as the wire would, so undefined-valued keys are dropped. @@ -129,6 +156,16 @@ describe("coding-agent-v1 contract on the produced run tree", () => { // Always-present identity keys with the frozen values. for (const [k, v] of ALWAYS) expect(md[k], `${k} on ${runType}`).toBe(v); + expect(md).not.toHaveProperty("ls_agent_kind"); + + let ownerType = runType === "subagent" ? "subagent" : "root"; + let parent = run.parent_run_id ? byId.get(run.parent_run_id) : undefined; + while (parent) { + if (classify(parent) === "subagent") ownerType = "subagent"; + parent = parent.parent_run_id ? byId.get(parent.parent_run_id) : undefined; + } + expect(md.ls_agent_type, `ls_agent_type on ${runType}`).toBe(ownerType); + // thread_id groups the whole tree on the conversation id. expect(md.thread_id).toBe(turn.conversationId); // Turn markers + versions land on every run (Cursor exposes turns). diff --git a/test/normalize.test.ts b/test/normalize.test.ts index 7d67aea..a08ade9 100644 --- a/test/normalize.test.ts +++ b/test/normalize.test.ts @@ -150,7 +150,9 @@ describe("extractMcpError", () => { }); it("ignores successful MCP calls (isError:false or absent)", () => { - expect(extractMcpError("MCP:foo", { content: [{ type: "text", text: "ok" }], isError: false })).toBeUndefined(); + expect( + extractMcpError("MCP:foo", { content: [{ type: "text", text: "ok" }], isError: false }), + ).toBeUndefined(); expect(extractMcpError("MCP:foo", { content: [] })).toBeUndefined(); }); diff --git a/test/system-prompt.test.ts b/test/system-prompt.test.ts index 4ec1580..7d5bf39 100644 --- a/test/system-prompt.test.ts +++ b/test/system-prompt.test.ts @@ -85,8 +85,12 @@ describe("systemContentOf", () => { expect(systemContentOf(buf)).toBe('[{"type":"text"}]'); }); it("ignores non-system messages, empty content, and malformed JSON", () => { - expect(systemContentOf(Buffer.from(JSON.stringify({ role: "user", content: "hi" })))).toBeUndefined(); - expect(systemContentOf(Buffer.from(JSON.stringify({ role: "system", content: "" })))).toBeUndefined(); + expect( + systemContentOf(Buffer.from(JSON.stringify({ role: "user", content: "hi" }))), + ).toBeUndefined(); + expect( + systemContentOf(Buffer.from(JSON.stringify({ role: "system", content: "" }))), + ).toBeUndefined(); expect(systemContentOf(Buffer.from("not json"))).toBeUndefined(); }); }); @@ -112,7 +116,10 @@ describe("resolveTurnSystemPrompt", () => { const map = new Map([ ["composerData:conv1", Buffer.from(JSON.stringify({ conversationState }))], ["agentKv:blob:dead", Buffer.from(JSON.stringify({ role: "user", content: "hi" }))], - ["agentKv:blob:beef", Buffer.from(JSON.stringify({ role: "system", content: "SYSTEM PROMPT" }))], + [ + "agentKv:blob:beef", + Buffer.from(JSON.stringify({ role: "system", content: "SYSTEM PROMPT" })), + ], ]); const got = resolveTurnSystemPrompt({ @@ -155,8 +162,7 @@ describe("resolveTurnSystemPrompt", () => { }); it("resolveSystemPrompts opens ONE connection for many conversations (deduped)", () => { - const stateOf = (idByte: number) => - "~" + lenField(1, Buffer.from([idByte])).toString("base64"); + const stateOf = (idByte: number) => "~" + lenField(1, Buffer.from([idByte])).toString("base64"); const map = new Map([ ["composerData:a", Buffer.from(JSON.stringify({ conversationState: stateOf(0xa1) }))], ["composerData:b", Buffer.from(JSON.stringify({ conversationState: stateOf(0xb2) }))],