diff --git a/plugins/app/ralphx-mcp-server/build/__tests__/learned-skill-tools.test.js b/plugins/app/ralphx-mcp-server/build/__tests__/learned-skill-tools.test.js index f08c55fb06..2678d3eacc 100644 --- a/plugins/app/ralphx-mcp-server/build/__tests__/learned-skill-tools.test.js +++ b/plugins/app/ralphx-mcp-server/build/__tests__/learned-skill-tools.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { learnedSkillEndpoint, learnedSkillTransportOptions, } from "../learned-skill-tools.js"; +import { LEARNED_SKILL_TOOLS, learnedSkillEndpoint, learnedSkillTransportOptions, } from "../learned-skill-tools.js"; describe("learned skill dispatch", () => { it("maps every read and write tool to its backend route", () => { expect(learnedSkillEndpoint("list_project_skills")).toBe("project_skills/list"); @@ -8,7 +8,7 @@ describe("learned skill dispatch", () => { expect(learnedSkillEndpoint("patch_project_skill")).toBe("project_skills/patch"); expect(learnedSkillEndpoint("retire_project_skill")).toBe("project_skills/retire"); }); - it("adds hidden runtime headers only to write calls", () => { + it("adds transport-owned read identity without changing model schemas", () => { const runtime = { filesystemEnforced: false, agentType: "ralphx-memory-maintainer", @@ -17,12 +17,20 @@ describe("learned skill dispatch", () => { contextType: "project", contextId: "project-1", conversationId: "conversation-1", + agentRunId: "run-1", }; - expect(learnedSkillTransportOptions("list_project_skills", runtime)).toBeUndefined(); - expect(learnedSkillTransportOptions("get_project_skill", runtime)).toBeUndefined(); + for (const toolName of ["list_project_skills", "get_project_skill"]) { + expect(learnedSkillTransportOptions(toolName, runtime)).toEqual({ + headers: { + "x-ralphx-conversation-id": "conversation-1", + "x-ralphx-agent-run-id": "run-1", + }, + }); + } expect(learnedSkillTransportOptions("upsert_project_skill", runtime)).toEqual({ headers: { "x-ralphx-agent-name": "ralphx-memory-maintainer", + "x-ralphx-agent-run-id": "run-1", "x-ralphx-pipeline-role": "memory_maintainer", "x-ralphx-project-id": "project-1", "x-ralphx-context-type": "project", @@ -30,6 +38,19 @@ describe("learned skill dispatch", () => { "x-ralphx-conversation-id": "conversation-1", }, }); + for (const tool of LEARNED_SKILL_TOOLS) { + expect(tool.inputSchema.properties).not.toHaveProperty("conversation_id"); + expect(tool.inputSchema.properties).not.toHaveProperty("agent_run_id"); + expect(tool.inputSchema.properties).not.toHaveProperty("orchestration_id"); + } + }); + it("uses bounded conversation identity for read calls when no run exists", () => { + expect(learnedSkillTransportOptions("get_project_skill", { + filesystemEnforced: false, + conversationId: "conversation-1", + })).toEqual({ + headers: { "x-ralphx-conversation-id": "conversation-1" }, + }); }); }); //# sourceMappingURL=learned-skill-tools.test.js.map \ No newline at end of file diff --git a/plugins/app/ralphx-mcp-server/build/__tests__/learned-skill-tools.test.js.map b/plugins/app/ralphx-mcp-server/build/__tests__/learned-skill-tools.test.js.map index 89d37f6903..3f20444e61 100644 --- a/plugins/app/ralphx-mcp-server/build/__tests__/learned-skill-tools.test.js.map +++ b/plugins/app/ralphx-mcp-server/build/__tests__/learned-skill-tools.test.js.map @@ -1 +1 @@ -{"version":3,"file":"learned-skill-tools.test.js","sourceRoot":"","sources":["../../src/__tests__/learned-skill-tools.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAE9C,OAAO,EACL,oBAAoB,EACpB,4BAA4B,GAC7B,MAAM,2BAA2B,CAAC;AAEnC,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACtC,EAAE,CAAC,qDAAqD,EAAE,GAAG,EAAE;QAC7D,MAAM,CAAC,oBAAoB,CAAC,qBAAqB,CAAC,CAAC,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;QAChF,MAAM,CAAC,oBAAoB,CAAC,mBAAmB,CAAC,CAAC,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC;QAC7E,MAAM,CAAC,oBAAoB,CAAC,sBAAsB,CAAC,CAAC,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC;QACnF,MAAM,CAAC,oBAAoB,CAAC,qBAAqB,CAAC,CAAC,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC;QACjF,MAAM,CAAC,oBAAoB,CAAC,sBAAsB,CAAC,CAAC,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC;IACrF,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iDAAiD,EAAE,GAAG,EAAE;QACzD,MAAM,OAAO,GAAG;YACd,kBAAkB,EAAE,KAAK;YACzB,SAAS,EAAE,0BAA0B;YACrC,YAAY,EAAE,mBAAmB;YACjC,SAAS,EAAE,WAAW;YACtB,WAAW,EAAE,SAAS;YACtB,SAAS,EAAE,WAAW;YACtB,cAAc,EAAE,gBAAgB;SACjC,CAAC;QAEF,MAAM,CAAC,4BAA4B,CAAC,qBAAqB,EAAE,OAAO,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;QACrF,MAAM,CAAC,4BAA4B,CAAC,mBAAmB,EAAE,OAAO,CAAC,CAAC,CAAC,aAAa,EAAE,CAAC;QACnF,MAAM,CAAC,4BAA4B,CAAC,sBAAsB,EAAE,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;YAC5E,OAAO,EAAE;gBACP,qBAAqB,EAAE,0BAA0B;gBACjD,wBAAwB,EAAE,mBAAmB;gBAC7C,qBAAqB,EAAE,WAAW;gBAClC,uBAAuB,EAAE,SAAS;gBAClC,qBAAqB,EAAE,WAAW;gBAClC,0BAA0B,EAAE,gBAAgB;aAC7C;SACF,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"} \ No newline at end of file +{"version":3,"file":"learned-skill-tools.test.js","sourceRoot":"","sources":["../../src/__tests__/learned-skill-tools.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAE9C,OAAO,EACL,mBAAmB,EACnB,oBAAoB,EACpB,4BAA4B,GAC7B,MAAM,2BAA2B,CAAC;AAEnC,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACtC,EAAE,CAAC,qDAAqD,EAAE,GAAG,EAAE;QAC7D,MAAM,CAAC,oBAAoB,CAAC,qBAAqB,CAAC,CAAC,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;QAChF,MAAM,CAAC,oBAAoB,CAAC,mBAAmB,CAAC,CAAC,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC;QAC7E,MAAM,CAAC,oBAAoB,CAAC,sBAAsB,CAAC,CAAC,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC;QACnF,MAAM,CAAC,oBAAoB,CAAC,qBAAqB,CAAC,CAAC,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC;QACjF,MAAM,CAAC,oBAAoB,CAAC,sBAAsB,CAAC,CAAC,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC;IACrF,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mEAAmE,EAAE,GAAG,EAAE;QAC3E,MAAM,OAAO,GAAG;YACd,kBAAkB,EAAE,KAAK;YACzB,SAAS,EAAE,0BAA0B;YACrC,YAAY,EAAE,mBAAmB;YACjC,SAAS,EAAE,WAAW;YACtB,WAAW,EAAE,SAAS;YACtB,SAAS,EAAE,WAAW;YACtB,cAAc,EAAE,gBAAgB;YAChC,UAAU,EAAE,OAAO;SACpB,CAAC;QAEF,KAAK,MAAM,QAAQ,IAAI,CAAC,qBAAqB,EAAE,mBAAmB,CAAC,EAAE,CAAC;YACpE,MAAM,CAAC,4BAA4B,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;gBAC9D,OAAO,EAAE;oBACP,0BAA0B,EAAE,gBAAgB;oBAC5C,uBAAuB,EAAE,OAAO;iBACjC;aACF,CAAC,CAAC;QACL,CAAC;QACD,MAAM,CAAC,4BAA4B,CAAC,sBAAsB,EAAE,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;YAC5E,OAAO,EAAE;gBACP,qBAAqB,EAAE,0BAA0B;gBACjD,uBAAuB,EAAE,OAAO;gBAChC,wBAAwB,EAAE,mBAAmB;gBAC7C,qBAAqB,EAAE,WAAW;gBAClC,uBAAuB,EAAE,SAAS;gBAClC,qBAAqB,EAAE,WAAW;gBAClC,0BAA0B,EAAE,gBAAgB;aAC7C;SACF,CAAC,CAAC;QAEH,KAAK,MAAM,IAAI,IAAI,mBAAmB,EAAE,CAAC;YACvC,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,cAAc,CAAC,iBAAiB,CAAC,CAAC;YAC1E,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,cAAc,CAAC,cAAc,CAAC,CAAC;YACvE,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,cAAc,CAAC,kBAAkB,CAAC,CAAC;QAC7E,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sEAAsE,EAAE,GAAG,EAAE;QAC9E,MAAM,CACJ,4BAA4B,CAAC,mBAAmB,EAAE;YAChD,kBAAkB,EAAE,KAAK;YACzB,cAAc,EAAE,gBAAgB;SACjC,CAAC,CACH,CAAC,OAAO,CAAC;YACR,OAAO,EAAE,EAAE,0BAA0B,EAAE,gBAAgB,EAAE;SAC1D,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/plugins/app/ralphx-mcp-server/build/learned-skill-tools.d.ts.map b/plugins/app/ralphx-mcp-server/build/learned-skill-tools.d.ts.map index d2342da768..f5ad3aaa7f 100644 --- a/plugins/app/ralphx-mcp-server/build/learned-skill-tools.d.ts.map +++ b/plugins/app/ralphx-mcp-server/build/learned-skill-tools.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"learned-skill-tools.d.ts","sourceRoot":"","sources":["../src/learned-skill-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,oCAAoC,CAAC;AAK1D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAiE1D,eAAO,MAAM,mBAAmB,EAAE,IAAI,EAkGrC,CAAC;AAEF,eAAO,MAAM,wBAAwB,UAA+C,CAAC;AAgBrF,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAM7D;AAED,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,MAAM,EAChB,cAAc,EAAE,cAAc,GAC7B,gBAAgB,GAAG,SAAS,CAM9B"} \ No newline at end of file +{"version":3,"file":"learned-skill-tools.d.ts","sourceRoot":"","sources":["../src/learned-skill-tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,oCAAoC,CAAC;AAO1D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAiE1D,eAAO,MAAM,mBAAmB,EAAE,IAAI,EAkGrC,CAAC;AAEF,eAAO,MAAM,wBAAwB,UAA+C,CAAC;AAiBrF,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAM7D;AAED,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,MAAM,EAChB,cAAc,EAAE,cAAc,GAC7B,gBAAgB,GAAG,SAAS,CAW9B"} \ No newline at end of file diff --git a/plugins/app/ralphx-mcp-server/build/learned-skill-tools.js b/plugins/app/ralphx-mcp-server/build/learned-skill-tools.js index c93b21ba6e..4302dd33c0 100644 --- a/plugins/app/ralphx-mcp-server/build/learned-skill-tools.js +++ b/plugins/app/ralphx-mcp-server/build/learned-skill-tools.js @@ -1,4 +1,4 @@ -import { buildProjectSkillPipelineTransportHeaders, } from "./runtime-context.js"; +import { buildProjectSkillPipelineTransportHeaders, buildRuntimeIdentityTransportHeaders, buildRuntimeTransportHeaders, } from "./runtime-context.js"; const PIPELINE_VALUES = [ "planning", "verification", @@ -165,6 +165,7 @@ const WRITE_TOOLS = new Set([ "patch_project_skill", "retire_project_skill", ]); +const READ_TOOLS = new Set(["list_project_skills", "get_project_skill"]); export function learnedSkillEndpoint(toolName) { const endpoint = ENDPOINT_BY_TOOL[toolName]; if (!endpoint) { @@ -174,7 +175,13 @@ export function learnedSkillEndpoint(toolName) { } export function learnedSkillTransportOptions(toolName, runtimeContext) { if (!WRITE_TOOLS.has(toolName)) { - return undefined; + if (!READ_TOOLS.has(toolName)) + return undefined; + const headers = { + ...(buildRuntimeTransportHeaders(runtimeContext) ?? {}), + ...(buildRuntimeIdentityTransportHeaders(runtimeContext) ?? {}), + }; + return Object.keys(headers).length > 0 ? { headers } : undefined; } const headers = buildProjectSkillPipelineTransportHeaders(runtimeContext); return headers ? { headers } : undefined; diff --git a/plugins/app/ralphx-mcp-server/build/learned-skill-tools.js.map b/plugins/app/ralphx-mcp-server/build/learned-skill-tools.js.map index 80c6875d53..d312d64e3c 100644 --- a/plugins/app/ralphx-mcp-server/build/learned-skill-tools.js.map +++ b/plugins/app/ralphx-mcp-server/build/learned-skill-tools.js.map @@ -1 +1 @@ -{"version":3,"file":"learned-skill-tools.js","sourceRoot":"","sources":["../src/learned-skill-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"learned-skill-tools.js","sourceRoot":"","sources":["../src/learned-skill-tools.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/plugins/app/ralphx-mcp-server/src/__tests__/learned-skill-tools.test.ts b/plugins/app/ralphx-mcp-server/src/__tests__/learned-skill-tools.test.ts index 814dfa6fc0..41c19e2f5f 100644 --- a/plugins/app/ralphx-mcp-server/src/__tests__/learned-skill-tools.test.ts +++ b/plugins/app/ralphx-mcp-server/src/__tests__/learned-skill-tools.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + LEARNED_SKILL_TOOLS, learnedSkillEndpoint, learnedSkillTransportOptions, } from "../learned-skill-tools.js"; @@ -14,7 +15,7 @@ describe("learned skill dispatch", () => { expect(learnedSkillEndpoint("retire_project_skill")).toBe("project_skills/retire"); }); - it("adds hidden runtime headers only to write calls", () => { + it("adds transport-owned read identity without changing model schemas", () => { const runtime = { filesystemEnforced: false, agentType: "ralphx-memory-maintainer", @@ -23,13 +24,21 @@ describe("learned skill dispatch", () => { contextType: "project", contextId: "project-1", conversationId: "conversation-1", + agentRunId: "run-1", }; - expect(learnedSkillTransportOptions("list_project_skills", runtime)).toBeUndefined(); - expect(learnedSkillTransportOptions("get_project_skill", runtime)).toBeUndefined(); + for (const toolName of ["list_project_skills", "get_project_skill"]) { + expect(learnedSkillTransportOptions(toolName, runtime)).toEqual({ + headers: { + "x-ralphx-conversation-id": "conversation-1", + "x-ralphx-agent-run-id": "run-1", + }, + }); + } expect(learnedSkillTransportOptions("upsert_project_skill", runtime)).toEqual({ headers: { "x-ralphx-agent-name": "ralphx-memory-maintainer", + "x-ralphx-agent-run-id": "run-1", "x-ralphx-pipeline-role": "memory_maintainer", "x-ralphx-project-id": "project-1", "x-ralphx-context-type": "project", @@ -37,5 +46,22 @@ describe("learned skill dispatch", () => { "x-ralphx-conversation-id": "conversation-1", }, }); + + for (const tool of LEARNED_SKILL_TOOLS) { + expect(tool.inputSchema.properties).not.toHaveProperty("conversation_id"); + expect(tool.inputSchema.properties).not.toHaveProperty("agent_run_id"); + expect(tool.inputSchema.properties).not.toHaveProperty("orchestration_id"); + } + }); + + it("uses bounded conversation identity for read calls when no run exists", () => { + expect( + learnedSkillTransportOptions("get_project_skill", { + filesystemEnforced: false, + conversationId: "conversation-1", + }) + ).toEqual({ + headers: { "x-ralphx-conversation-id": "conversation-1" }, + }); }); }); diff --git a/plugins/app/ralphx-mcp-server/src/learned-skill-tools.ts b/plugins/app/ralphx-mcp-server/src/learned-skill-tools.ts index 9ba3f8d1b6..1bf75c4bbe 100644 --- a/plugins/app/ralphx-mcp-server/src/learned-skill-tools.ts +++ b/plugins/app/ralphx-mcp-server/src/learned-skill-tools.ts @@ -2,6 +2,8 @@ import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { buildProjectSkillPipelineTransportHeaders, + buildRuntimeIdentityTransportHeaders, + buildRuntimeTransportHeaders, } from "./runtime-context.js"; import type { RuntimeContext } from "./runtime-context.js"; import type { TauriCallOptions } from "./tauri-client.js"; @@ -184,6 +186,7 @@ const WRITE_TOOLS = new Set([ "patch_project_skill", "retire_project_skill", ]); +const READ_TOOLS = new Set(["list_project_skills", "get_project_skill"]); export function learnedSkillEndpoint(toolName: string): string { const endpoint = ENDPOINT_BY_TOOL[toolName]; @@ -198,7 +201,12 @@ export function learnedSkillTransportOptions( runtimeContext: RuntimeContext ): TauriCallOptions | undefined { if (!WRITE_TOOLS.has(toolName)) { - return undefined; + if (!READ_TOOLS.has(toolName)) return undefined; + const headers = { + ...(buildRuntimeTransportHeaders(runtimeContext) ?? {}), + ...(buildRuntimeIdentityTransportHeaders(runtimeContext) ?? {}), + }; + return Object.keys(headers).length > 0 ? { headers } : undefined; } const headers = buildProjectSkillPipelineTransportHeaders(runtimeContext); return headers ? { headers } : undefined; diff --git a/src-tauri/crates/ralphx-domain/src/repositories/skill_usage_event_repository.rs b/src-tauri/crates/ralphx-domain/src/repositories/skill_usage_event_repository.rs index 9776c51adf..3ffcbd80f9 100644 --- a/src-tauri/crates/ralphx-domain/src/repositories/skill_usage_event_repository.rs +++ b/src-tauri/crates/ralphx-domain/src/repositories/skill_usage_event_repository.rs @@ -14,6 +14,14 @@ pub struct SkillUsageListOptions { pub trait SkillUsageEventRepository: Send + Sync { async fn record(&self, event: SkillUsageEvent) -> AppResult; + async fn record_batch(&self, events: Vec) -> AppResult> { + let mut saved = Vec::with_capacity(events.len()); + for event in events { + saved.push(self.record(event).await?); + } + Ok(saved) + } + async fn list_by_project( &self, project_id: &ProjectId, diff --git a/src-tauri/src/application/chat_service/chat_service_context.rs b/src-tauri/src/application/chat_service/chat_service_context.rs index 3f30802c2c..853647dd8a 100644 --- a/src-tauri/src/application/chat_service/chat_service_context.rs +++ b/src-tauri/src/application/chat_service/chat_service_context.rs @@ -363,6 +363,10 @@ impl ProviderSpawnableCommand { ); } + pub fn injected_skill_names(&self) -> &[String] { + self.spawnable.injected_skill_names() + } + #[doc(hidden)] pub fn persona_injected(&self) -> bool { self.spawnable.persona_injected() @@ -674,6 +678,14 @@ impl ResolvedChatHarnessLaunch { } } + pub fn injected_skill_names(&self) -> &[String] { + match self { + Self::Interactive { spawnable, .. } | Self::Background { spawnable, .. } => { + spawnable.injected_skill_names() + } + } + } + pub fn apply_provider_env(&mut self, provider_env: &HashMap) { match self { Self::Interactive { spawnable, .. } | Self::Background { spawnable, .. } => { @@ -3435,6 +3447,7 @@ pub async fn build_codex_command( ); let CodexPromptComposition { prompt, + injected_skill_names, persona_injected, persona_injection_skipped_reason, } = compose_codex_prompt_for_profile_with_learned_skills_and_outcome( @@ -3494,7 +3507,8 @@ pub async fn build_codex_command( conversation_launch_security_class(conversation.context_type, conversation.agent_mode) .codex_security_policy(), )? - .with_persona_injection_outcome(persona_injected, persona_injection_skipped_reason); + .with_persona_injection_outcome(persona_injected, persona_injection_skipped_reason) + .with_injected_skill_names(injected_skill_names); tracing::info!( context_type = %conversation.context_type, context_id = %conversation.context_id, @@ -4585,6 +4599,7 @@ pub async fn build_codex_resume_command( ); let CodexPromptComposition { prompt, + injected_skill_names, persona_injected, persona_injection_skipped_reason, } = compose_codex_prompt_for_profile_with_learned_skills_and_outcome( @@ -4605,7 +4620,8 @@ pub async fn build_codex_resume_command( conversation_launch_security_class(context_type, effective_mode) .codex_security_policy(), )? - .with_persona_injection_outcome(persona_injected, persona_injection_skipped_reason); + .with_persona_injection_outcome(persona_injected, persona_injection_skipped_reason) + .with_injected_skill_names(injected_skill_names); apply_ralphx_env_vars( &mut spawnable, @@ -4658,6 +4674,7 @@ pub async fn build_codex_resume_command( let CodexPromptComposition { prompt, + injected_skill_names, persona_injected, persona_injection_skipped_reason, } = compose_codex_prompt_for_profile_with_learned_skills_and_outcome( @@ -4676,7 +4693,8 @@ pub async fn build_codex_resume_command( conversation_launch_security_class(context_type, effective_mode) .codex_security_policy(), )? - .with_persona_injection_outcome(persona_injected, persona_injection_skipped_reason); + .with_persona_injection_outcome(persona_injected, persona_injection_skipped_reason) + .with_injected_skill_names(injected_skill_names); apply_ralphx_env_vars( &mut spawnable, diff --git a/src-tauri/src/application/chat_service/chat_service_context_tests.rs b/src-tauri/src/application/chat_service/chat_service_context_tests.rs index 858493b85b..0b6c4c6524 100644 --- a/src-tauri/src/application/chat_service/chat_service_context_tests.rs +++ b/src-tauri/src/application/chat_service/chat_service_context_tests.rs @@ -972,6 +972,16 @@ async fn c1_fresh_recovery_and_resume_builders_inject_for_both_harnesses() { .await .expect("fresh launch should build"); let fresh_prompt = c1_spawnable_prompt(launch_spawnable(&launch)); + let expected_fresh_names = if harness == AgentHarnessKind::Claude { + vec!["learned:skill-execution"] + } else { + vec!["learned:skill-current-claude", "learned:skill-execution"] + }; + assert_eq!( + launch.injected_skill_names(), + expected_fresh_names, + "fresh {harness} launch should expose every currently eligible C1 name" + ); assert!(fresh_prompt.contains("skill-execution")); assert!(!fresh_prompt.contains("skill-staged")); assert!(!fresh_prompt.contains("skill-review")); @@ -1015,6 +1025,16 @@ async fn c1_fresh_recovery_and_resume_builders_inject_for_both_harnesses() { .await .expect("recovery command should build"); let recovery_prompt = c1_spawnable_prompt(&recovery.spawnable); + let mut expected_recovery_names = vec![format!("learned:{retry_skill_id}")]; + if harness == AgentHarnessKind::Codex { + expected_recovery_names.insert(0, "learned:skill-current-claude".to_string()); + } + expected_recovery_names.push("learned:skill-execution".to_string()); + assert_eq!( + recovery.injected_skill_names(), + expected_recovery_names, + "recovery {harness} command should expose every currently eligible C1 name" + ); assert!(recovery_prompt.contains("skill-execution")); assert!( recovery_prompt.contains(&retry_skill_id), @@ -1061,6 +1081,10 @@ async fn c1_fresh_recovery_and_resume_builders_inject_for_both_harnesses() { .await .expect("resume command should build"); assert!(c1_spawnable_prompt(&resumed.spawnable).contains("skill-execution")); + assert!(resumed + .injected_skill_names() + .iter() + .any(|name| name == "learned:skill-execution")); let unavailable = build_command_for_harness_with_folder_refs( harness, @@ -1092,6 +1116,7 @@ async fn c1_fresh_recovery_and_resume_builders_inject_for_both_harnesses() { .expect("unavailable project skills must not block spawn"); assert!(!c1_spawnable_prompt(&unavailable.spawnable) .contains("")); + assert!(unavailable.injected_skill_names().is_empty()); } } diff --git a/src-tauri/src/application/chat_service/chat_service_handlers_tests.rs b/src-tauri/src/application/chat_service/chat_service_handlers_tests.rs index 41bd1727cf..9a3fea24a5 100644 --- a/src-tauri/src/application/chat_service/chat_service_handlers_tests.rs +++ b/src-tauri/src/application/chat_service/chat_service_handlers_tests.rs @@ -2173,6 +2173,15 @@ async fn test_interactive_stdin_learned_skill_usage_is_recorded_as_unscored() { .record_learned_skill_usage_for_interactive_stdin( Some(project_id.as_str()), &conversation_id, + "message-1", + &[skill.clone()], + ) + .await; + service + .record_learned_skill_usage_for_interactive_stdin( + Some(project_id.as_str()), + &conversation_id, + "message-1", &[skill.clone()], ) .await; @@ -2197,9 +2206,10 @@ async fn test_interactive_stdin_learned_skill_usage_is_recorded_as_unscored() { ); assert_eq!(event.metadata_json["scoring_eligible"], false); assert_eq!( - event.metadata_json["scoring_disabled_reason"], - "interactive_stdin_turn_has_no_new_agent_run_id" + event.metadata_json["exclusion_reason"], + "interactive_stdin_has_no_exact_agent_run" ); + assert_eq!(event.metadata_json["source_turn_id"], "message-1"); } // ======================================== diff --git a/src-tauri/src/application/chat_service/learned_skill_usage_tests.rs b/src-tauri/src/application/chat_service/learned_skill_usage_tests.rs new file mode 100644 index 0000000000..308e373562 --- /dev/null +++ b/src-tauri/src/application/chat_service/learned_skill_usage_tests.rs @@ -0,0 +1,376 @@ +use std::sync::Arc; +use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; +use tauri::Manager; + +use crate::application::chat_service::ClaudeChatService; +use crate::application::AppState; +use crate::domain::agents::AgentHarnessKind; +use crate::domain::entities::{ + ChatConversationId, ProjectId, ProjectSkill, ProjectSkillId, ProjectSkillLifecycleStatus, + SkillUsageEvent, SkillUsageInjectionKind, +}; +use crate::domain::repositories::{SkillUsageEventRepository, SkillUsageListOptions}; +use crate::infrastructure::memory::MemorySkillUsageEventRepository; + +struct LaunchUsageFixture { + app: tauri::App, + usage_repo: Arc, +} + +impl LaunchUsageFixture { + fn new() -> Self { + let mut app_state = AppState::new_test(); + let usage_repo = Arc::new(MemorySkillUsageEventRepository::new()); + app_state.skill_usage_event_repo = Arc::clone(&usage_repo) as _; + let app = mock_builder() + .manage(app_state) + .build(mock_context(noop_assets())) + .expect("mock app"); + Self { app, usage_repo } + } + + fn service(&self) -> ClaudeChatService { + let handle = self.app.handle().clone(); + let state = handle.state::(); + ClaudeChatService::::new( + Arc::clone(&state.chat_message_repo), + Arc::clone(&state.chat_attachment_repo), + Arc::clone(&state.artifact_repo), + Arc::clone(&state.chat_conversation_repo), + Arc::clone(&state.agent_run_repo), + Arc::clone(&state.project_repo), + Arc::clone(&state.task_repo), + Arc::clone(&state.task_dependency_repo), + Arc::clone(&state.ideation_session_repo), + Arc::clone(&state.delegated_session_repo), + Arc::clone(&state.activity_event_repo), + Arc::clone(&state.message_queue), + Arc::clone(&state.running_agent_registry), + Arc::clone(&state.memory_event_repo), + Arc::clone(&state.project_memory_settings_repo), + ) + .with_app_handle(handle.clone()) + } + + async fn seed_skill(&self, project_id: &ProjectId) -> ProjectSkill { + let handle = self.app.handle().clone(); + let state = handle.state::(); + let skill = build_skill(project_id); + state + .project_skill_repo + .create(skill.clone()) + .await + .expect("seed project skill"); + skill + } + + async fn usage(&self, project_id: &ProjectId) -> Vec { + self.usage_repo + .list_by_project(project_id, SkillUsageListOptions::default()) + .await + .expect("list usage") + } +} + +fn build_skill(project_id: &ProjectId) -> ProjectSkill { + let now = chrono::Utc::now(); + ProjectSkill { + id: ProjectSkillId::new(), + project_id: project_id.clone(), + title: "Use planning constraints".to_string(), + bucket: "planning".to_string(), + stage: "planning".to_string(), + status: ProjectSkillLifecycleStatus::Approved, + pinned: false, + archived: false, + scope_paths: Vec::new(), + compact_guidance: "Carry approved planning constraints into the next turn.".to_string(), + body_markdown: "Detailed guidance".to_string(), + predicted_effect: Some("Avoids dropping accepted planning constraints.".to_string()), + provenance_json: serde_json::json!({ "test": true }), + companion_of_skill_id: None, + content_hash: String::new(), + evidence_hash: String::new(), + created_by: crate::domain::entities::ProjectSkillCreatedBy::User, + pipeline_role: None, + created_at: now, + updated_at: now, + } +} + +fn kinds_of(events: &[SkillUsageEvent]) -> Vec { + let mut kinds: Vec<_> = events.iter().map(|event| event.injection_kind).collect(); + kinds.sort_by_key(|kind| format!("{kind:?}")); + kinds +} + +fn event_of<'a>( + events: &'a [SkillUsageEvent], + kind: SkillUsageInjectionKind, +) -> &'a SkillUsageEvent { + events + .iter() + .find(|event| event.injection_kind == kind) + .unwrap_or_else(|| panic!("expected a {kind:?} usage event")) +} + +#[tokio::test] +async fn test_launch_usage_records_compact_index_and_composer_directive_rows() { + let fixture = LaunchUsageFixture::new(); + let service = fixture.service(); + let project_id = ProjectId::new(); + let conversation_id = ChatConversationId::new(); + let skill = fixture.seed_skill(&project_id).await; + let injected_name = format!("learned:{}", skill.id.as_str()); + + service + .record_learned_skill_usage_for_launch( + Some(project_id.as_str()), + &conversation_id, + "run-1", + AgentHarnessKind::Claude, + &[injected_name.clone(), "internal-skill-name".to_string()], + &[skill.clone()], + ) + .await; + + let usage = fixture.usage(&project_id).await; + assert_eq!( + usage.len(), + 2, + "one compact-index and one composer-directive row; the internal name records nothing" + ); + assert_eq!( + kinds_of(&usage), + vec![ + SkillUsageInjectionKind::CompactIndex, + SkillUsageInjectionKind::ComposerDirective, + ] + ); + + for event in &usage { + assert_eq!(event.project_skill_id, skill.id); + assert_eq!( + event.conversation_id.as_deref(), + Some(conversation_id.as_str().as_str()) + ); + assert_eq!(event.agent_run_id.as_deref(), Some("run-1")); + assert_eq!( + event.provider_harness.as_deref(), + Some(AgentHarnessKind::Claude.to_string().as_str()) + ); + assert_eq!(event.stage.as_deref(), Some(skill.stage.as_str())); + assert_eq!(event.bucket.as_deref(), Some(skill.bucket.as_str())); + assert_eq!(event.outcome_id, None); + assert_eq!(event.metadata_json["scoring_eligible"], true); + } +} + +#[tokio::test] +async fn test_launch_usage_records_metadata_provenance_per_kind() { + let fixture = LaunchUsageFixture::new(); + let service = fixture.service(); + let project_id = ProjectId::new(); + let conversation_id = ChatConversationId::new(); + let skill = fixture.seed_skill(&project_id).await; + let injected_name = format!("learned:{}", skill.id.as_str()); + + service + .record_learned_skill_usage_for_launch( + Some(project_id.as_str()), + &conversation_id, + "run-1", + AgentHarnessKind::Claude, + &[injected_name.clone()], + &[skill.clone()], + ) + .await; + + let usage = fixture.usage(&project_id).await; + let compact = event_of(&usage, SkillUsageInjectionKind::CompactIndex); + assert_eq!( + compact.metadata_json["source"], + "pre_execution_project_skill_injection" + ); + assert_eq!(compact.metadata_json["injected_skill_name"], injected_name); + + let composer = event_of(&usage, SkillUsageInjectionKind::ComposerDirective); + assert_eq!( + composer.metadata_json["source"], + "ralphx_project_skill_directive" + ); +} + +#[tokio::test] +async fn test_launch_usage_same_run_retry_is_idempotent_and_distinct_runs_are_not_collapsed() { + let fixture = LaunchUsageFixture::new(); + let service = fixture.service(); + let project_id = ProjectId::new(); + let conversation_id = ChatConversationId::new(); + let skill = fixture.seed_skill(&project_id).await; + let injected_name = format!("learned:{}", skill.id.as_str()); + + for _ in 0..2 { + service + .record_learned_skill_usage_for_launch( + Some(project_id.as_str()), + &conversation_id, + "run-1", + AgentHarnessKind::Claude, + &[injected_name.clone()], + &[skill.clone()], + ) + .await; + } + assert_eq!( + fixture.usage(&project_id).await.len(), + 2, + "retrying the same run must not duplicate rows" + ); + + service + .record_learned_skill_usage_for_launch( + Some(project_id.as_str()), + &conversation_id, + "run-2", + AgentHarnessKind::Claude, + &[injected_name], + &[skill], + ) + .await; + assert_eq!( + fixture.usage(&project_id).await.len(), + 4, + "a distinct run must record its own rows" + ); +} + +#[tokio::test] +async fn test_launch_usage_unresolved_injected_skill_suppresses_whole_batch() { + let fixture = LaunchUsageFixture::new(); + let service = fixture.service(); + let project_id = ProjectId::new(); + let conversation_id = ChatConversationId::new(); + let skill = fixture.seed_skill(&project_id).await; + + service + .record_learned_skill_usage_for_launch( + Some(project_id.as_str()), + &conversation_id, + "run-1", + AgentHarnessKind::Claude, + &["learned:does-not-exist".to_string()], + &[skill], + ) + .await; + + assert!( + fixture.usage(&project_id).await.is_empty(), + "an unresolved injected skill suppresses the entire launch batch" + ); +} + +#[tokio::test] +async fn test_launch_usage_suppresses_cross_project_injected_skill() { + let fixture = LaunchUsageFixture::new(); + let service = fixture.service(); + let project_id = ProjectId::new(); + let other_project_id = ProjectId::new(); + let conversation_id = ChatConversationId::new(); + let foreign_skill = fixture.seed_skill(&other_project_id).await; + let injected_name = format!("learned:{}", foreign_skill.id.as_str()); + + service + .record_learned_skill_usage_for_launch( + Some(project_id.as_str()), + &conversation_id, + "run-1", + AgentHarnessKind::Claude, + &[injected_name], + &[], + ) + .await; + + assert!(fixture.usage(&project_id).await.is_empty()); + assert!(fixture.usage(&other_project_id).await.is_empty()); +} + +#[tokio::test] +async fn test_launch_usage_suppresses_cross_project_selected_skill() { + let fixture = LaunchUsageFixture::new(); + let service = fixture.service(); + let project_id = ProjectId::new(); + let other_project_id = ProjectId::new(); + let conversation_id = ChatConversationId::new(); + let local_skill = fixture.seed_skill(&project_id).await; + let foreign_skill = fixture.seed_skill(&other_project_id).await; + let injected_name = format!("learned:{}", local_skill.id.as_str()); + + service + .record_learned_skill_usage_for_launch( + Some(project_id.as_str()), + &conversation_id, + "run-1", + AgentHarnessKind::Claude, + &[injected_name], + &[foreign_skill], + ) + .await; + + assert!(fixture.usage(&project_id).await.is_empty()); + assert!(fixture.usage(&other_project_id).await.is_empty()); +} + +#[tokio::test] +async fn test_launch_usage_requires_project_scope() { + let fixture = LaunchUsageFixture::new(); + let service = fixture.service(); + let project_id = ProjectId::new(); + let conversation_id = ChatConversationId::new(); + let skill = fixture.seed_skill(&project_id).await; + let injected_name = format!("learned:{}", skill.id.as_str()); + + for scope in [None, Some(" ")] { + service + .record_learned_skill_usage_for_launch( + scope, + &conversation_id, + "run-1", + AgentHarnessKind::Claude, + &[injected_name.clone()], + &[skill.clone()], + ) + .await; + assert!( + fixture.usage(&project_id).await.is_empty(), + "a missing or blank project scope must record nothing" + ); + } +} + +#[tokio::test] +async fn test_launch_usage_persistence_failure_is_contained() { + let fixture = LaunchUsageFixture::new(); + let service = fixture.service(); + let project_id = ProjectId::new(); + let conversation_id = ChatConversationId::new(); + let skill = fixture.seed_skill(&project_id).await; + let injected_name = format!("learned:{}", skill.id.as_str()); + + fixture.usage_repo.fail_next_batch_for_test(); + service + .record_learned_skill_usage_for_launch( + Some(project_id.as_str()), + &conversation_id, + "run-1", + AgentHarnessKind::Claude, + &[injected_name], + &[skill], + ) + .await; + + assert!( + fixture.usage(&project_id).await.is_empty(), + "an atomic telemetry failure leaves zero rows and is not an execution gate" + ); +} diff --git a/src-tauri/src/application/chat_service/mod.rs b/src-tauri/src/application/chat_service/mod.rs index 5a2827ed38..e8a0af1b8a 100644 --- a/src-tauri/src/application/chat_service/mod.rs +++ b/src-tauri/src/application/chat_service/mod.rs @@ -109,11 +109,11 @@ use crate::domain::repositories::{ ValidationRunRepository, }; use crate::domain::services::{ - is_process_alive, kill_process, new_skill_usage_event, AttachProcessResult, + is_process_alive, kill_process, new_c2_skill_usage_event, AttachProcessResult, ComposerArtifactReference, ComposerExcerptReference, ComposerIntegrationReference, ComposerProjectReference, ComposerSelectionSnapshot, MessageQueue, ProjectSkillService, QueueKey, QueuedMessage, RunningAgentInfo, RunningAgentKey, RunningAgentRegistry, - SkillUsageService, TryRegisterError, + SkillUsageAttribution, SkillUsageService, TryRegisterError, }; use crate::domain::state_machine::services::WebhookPublisher; use crate::infrastructure::agents::internal_skills::inject_learned_skill_citations_into_system_prompt; @@ -4260,6 +4260,7 @@ impl AppChatService { tokio::process::Child, Option>, Option, + Vec, ), ChatServiceError, > { @@ -4426,6 +4427,7 @@ impl AppChatService { ); let launch_mode = launch_plan.launch_mode(); + let injected_skill_names = launch_plan.injected_skill_names().to_vec(); tracing::info!(mode = ?launch_mode, plan = ?launch_plan, "Spawning chat harness agent"); let process_spawn_started = Instant::now(); let launched = launch_plan.spawn().await.map_err(|error| { @@ -4499,6 +4501,7 @@ impl AppChatService { launched.child, Some(self.ipr()), Some(interactive_process_token), + injected_skill_names, )) } else { tracing::info!( @@ -4509,7 +4512,13 @@ impl AppChatService { total_elapsed_ms = spawn_total_started.elapsed().as_millis() as u64, "chat_service.send_message spawn process completed" ); - Ok((launched.cli_path, launched.child, None, None)) + Ok(( + launched.cli_path, + launched.child, + None, + None, + injected_skill_names, + )) } } @@ -4747,9 +4756,10 @@ impl AppChatService { conversation_id: &ChatConversationId, agent_run_id: &str, harness: AgentHarnessKind, + injected_skill_names: &[String], selected_skills: &[ProjectSkill], ) { - if selected_skills.is_empty() { + if selected_skills.is_empty() && injected_skill_names.is_empty() { return; } let Some(project_id) = project_id.map(str::trim).filter(|value| !value.is_empty()) else { @@ -4764,36 +4774,112 @@ impl AppChatService { }; let project_id = ProjectId::from_string(project_id.to_string()); - let service = SkillUsageService::new(Arc::clone(&app_state.skill_usage_event_repo)); + let attribution = SkillUsageAttribution::ExactRun { + conversation_id: conversation_id.as_str().to_string(), + agent_run_id: agent_run_id.to_string(), + provider_harness: harness.to_string(), + stage: None, + bucket: None, + }; + let mut events = Vec::new(); + for injected_name in injected_skill_names { + let Some(skill_id) = injected_name.strip_prefix("learned:") else { + continue; + }; + let Ok(Some(skill)) = app_state + .project_skill_repo + .get_by_id(&crate::domain::entities::ProjectSkillId::from_string(skill_id)) + .await + else { + tracing::warn!( + injected_skill_name = injected_name, + project_id = project_id.as_str(), + "Suppressing learned skill injection telemetry for an unresolved skill" + ); + return; + }; + if skill.project_id != project_id { + tracing::warn!( + injected_skill_name = injected_name, + project_id = project_id.as_str(), + "Suppressing cross-project learned skill injection telemetry" + ); + return; + } + let mut event_attribution = attribution.clone(); + if let SkillUsageAttribution::ExactRun { stage, bucket, .. } = &mut event_attribution { + *stage = Some(skill.stage.clone()); + *bucket = Some(skill.bucket.clone()); + } + match new_c2_skill_usage_event( + project_id.clone(), + skill.id, + SkillUsageInjectionKind::CompactIndex, + event_attribution, + ) { + Ok(mut event) => { + event.metadata_json["source"] = + serde_json::json!("pre_execution_project_skill_injection"); + event.metadata_json["injected_skill_name"] = serde_json::json!(injected_name); + events.push(event); + } + Err(error) => { + tracing::warn!(error = %error, "Suppressing invalid learned skill launch telemetry batch"); + return; + } + } + } for skill in selected_skills { - let mut event = new_skill_usage_event( + if skill.project_id != project_id { + tracing::warn!( + project_skill_id = skill.id.as_str(), + project_id = project_id.as_str(), + "Suppressing cross-project learned skill launch telemetry batch" + ); + return; + } + let event = new_c2_skill_usage_event( project_id.clone(), skill.id.clone(), SkillUsageInjectionKind::ComposerDirective, + SkillUsageAttribution::ExactRun { + conversation_id: conversation_id.as_str().to_string(), + agent_run_id: agent_run_id.to_string(), + provider_harness: harness.to_string(), + stage: Some(skill.stage.clone()), + bucket: Some(skill.bucket.clone()), + }, ); - event.conversation_id = Some(conversation_id.as_str().to_string()); - event.agent_run_id = Some(agent_run_id.to_string()); - event.provider_harness = Some(harness.to_string()); - event.stage = Some(skill.stage.clone()); - event.bucket = Some(skill.bucket.clone()); - event.metadata_json = serde_json::json!({ - "source": "ralphx_project_skill_directive", - }); - if let Err(error) = service.record_usage(event).await { - tracing::warn!( - project_skill_id = skill.id.as_str(), - agent_run_id, - error = %error, - "Failed to record learned project skill usage" - ); + match event { + Ok(mut event) => { + event.metadata_json["source"] = + serde_json::json!("ralphx_project_skill_directive"); + events.push(event); + } + Err(error) => { + tracing::warn!(error = %error, "Suppressing invalid learned skill launch telemetry batch"); + return; + } } } + if events.is_empty() { + return; + } + let service = SkillUsageService::new(Arc::clone(&app_state.skill_usage_event_repo)); + if let Err(error) = service.record_usage_batch(events).await { + tracing::warn!( + agent_run_id, + error = %error, + "Failed to atomically record learned project skill launch usage" + ); + } } async fn record_learned_skill_usage_for_interactive_stdin( &self, project_id: Option<&str>, conversation_id: &ChatConversationId, + source_turn_id: &str, selected_skills: &[ProjectSkill], ) { if selected_skills.is_empty() { @@ -4812,30 +4898,38 @@ impl AppChatService { let project_id = ProjectId::from_string(project_id.to_string()); let service = SkillUsageService::new(Arc::clone(&app_state.skill_usage_event_repo)); + let mut events = Vec::with_capacity(selected_skills.len()); for skill in selected_skills { - let mut event = new_skill_usage_event( + match new_c2_skill_usage_event( project_id.clone(), skill.id.clone(), SkillUsageInjectionKind::InteractiveStdinUnattributed, - ); - event.conversation_id = Some(conversation_id.as_str().to_string()); - event.stage = Some(skill.stage.clone()); - event.bucket = Some(skill.bucket.clone()); - event.metadata_json = serde_json::json!({ - "source": "ralphx_project_skill_directive", - "attribution_scope": "interactive_stdin_turn", - "scoring_eligible": false, - "scoring_disabled_reason": "interactive_stdin_turn_has_no_new_agent_run_id", - }); - if let Err(error) = service.record_usage(event).await { - tracing::warn!( - project_skill_id = skill.id.as_str(), - conversation_id = conversation_id.as_str(), - error = %error, - "Failed to record unattributed interactive learned project skill usage" - ); + SkillUsageAttribution::InteractiveStdin { + conversation_id: conversation_id.as_str().to_string(), + source_turn_id: source_turn_id.to_string(), + stage: Some(skill.stage.clone()), + bucket: Some(skill.bucket.clone()), + }, + ) { + Ok(mut event) => { + event.metadata_json["source"] = + serde_json::json!("ralphx_project_skill_directive"); + events.push(event); + } + Err(error) => { + tracing::warn!(error = %error, "Suppressing invalid interactive learned skill telemetry batch"); + return; + } } } + if let Err(error) = service.record_usage_batch(events).await { + tracing::warn!( + conversation_id = conversation_id.as_str(), + source_turn_id, + error = %error, + "Failed to atomically record interactive learned project skill usage" + ); + } } async fn load_edit_mode_plan_handoff_artifact( @@ -5786,12 +5880,6 @@ impl ChatService for AppChatService { let (runtime_message, selected_learned_skills) = self .learned_skill_runtime_message(interactive_project_id.as_deref(), runtime_message) .await; - self.record_learned_skill_usage_for_interactive_stdin( - interactive_project_id.as_deref(), - &conversation.id, - &selected_learned_skills, - ) - .await; let stdin_prompt = chat_service_context::build_initial_prompt( context_type, context_id, @@ -5832,18 +5920,29 @@ impl ChatService for AppChatService { if let Some(user_msg) = pending_user_message { let user_msg_id = user_msg.id.as_str().to_string(); let user_msg_created_at = user_msg.created_at.to_rfc3339(); - if self - .chat_message_repo - .create(user_msg.clone()) - .await - .is_ok() - && !hide_user_message - { - chat_service_streaming::persist_message_text_timeline_item( - &self.chat_timeline_repo, - &user_msg, - ) - .await; + match self.chat_message_repo.create(user_msg.clone()).await { + Ok(_) => { + self.record_learned_skill_usage_for_interactive_stdin( + interactive_project_id.as_deref(), + &conversation.id, + &user_msg_id, + &selected_learned_skills, + ) + .await; + if !hide_user_message { + chat_service_streaming::persist_message_text_timeline_item( + &self.chat_timeline_repo, + &user_msg, + ) + .await; + } + } + Err(error) => tracing::warn!( + conversation_id = conversation.id.as_str(), + source_turn_id = user_msg_id, + error = %error, + "Suppressing interactive learned skill telemetry because the source turn was not persisted" + ), } self.link_turn_attachments(&turn_attachments, &user_msg_id) .await?; @@ -7350,8 +7449,13 @@ impl ChatService for AppChatService { let (runtime_message, selected_learned_skills) = self .learned_skill_runtime_message(project_id.as_deref(), runtime_message) .await; - let (selected_cli_path, mut child, interactive_process_registry, interactive_process_token) = - match self + let ( + selected_cli_path, + mut child, + interactive_process_registry, + interactive_process_token, + injected_skill_names, + ) = match self .spawn_process_for_harness( &conversation, &runtime_message, @@ -7382,6 +7486,7 @@ impl ChatService for AppChatService { &conversation_id, &agent_run_id, resolved_spawn_settings.effective_harness, + &injected_skill_names, &selected_learned_skills, ) .await; @@ -9970,6 +10075,8 @@ mod freshness_routing_tests; #[cfg(test)] mod interactive_runtime_tests; #[cfg(test)] +mod learned_skill_usage_tests; +#[cfg(test)] mod resolved_conversation_spawn_context_tests; #[cfg(test)] mod task_runtime_context_tests; diff --git a/src-tauri/src/domain/services/learned_skill_substrate.rs b/src-tauri/src/domain/services/learned_skill_substrate.rs index 381312955d..c1824580f0 100644 --- a/src-tauri/src/domain/services/learned_skill_substrate.rs +++ b/src-tauri/src/domain/services/learned_skill_substrate.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use chrono::{DateTime, Utc}; use serde_json::Value; +use sha2::{Digest, Sha256}; use crate::domain::entities::types::ProjectId; use crate::domain::entities::{ @@ -779,6 +780,13 @@ impl SkillUsageService { self.repo.record(event).await } + pub async fn record_usage_batch( + &self, + events: Vec, + ) -> AppResult> { + self.repo.record_batch(events).await + } + pub async fn list_project_usage( &self, project_id: &ProjectId, @@ -788,6 +796,171 @@ impl SkillUsageService { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkillUsageAttribution { + ExactRun { + conversation_id: String, + agent_run_id: String, + provider_harness: String, + stage: Option, + bucket: Option, + }, + BoundedConversation { + conversation_id: String, + reason: String, + stage: Option, + bucket: Option, + }, + InteractiveStdin { + conversation_id: String, + source_turn_id: String, + stage: Option, + bucket: Option, + }, +} + +pub fn new_c2_skill_usage_event( + project_id: ProjectId, + project_skill_id: ProjectSkillId, + injection_kind: SkillUsageInjectionKind, + attribution: SkillUsageAttribution, +) -> AppResult { + validate_non_empty("skill usage project_id", project_id.as_str())?; + validate_non_empty("skill usage project_skill_id", project_skill_id.as_str())?; + + let (conversation_id, agent_run_id, provider_harness, stage, bucket, anchor, metadata_json) = + match (injection_kind, attribution) { + ( + SkillUsageInjectionKind::CompactIndex + | SkillUsageInjectionKind::ComposerDirective + | SkillUsageInjectionKind::FullLoad, + SkillUsageAttribution::ExactRun { + conversation_id, + agent_run_id, + provider_harness, + stage, + bucket, + }, + ) => { + validate_non_empty("skill usage conversation_id", &conversation_id)?; + validate_non_empty("skill usage agent_run_id", &agent_run_id)?; + validate_non_empty("skill usage provider_harness", &provider_harness)?; + let anchor = agent_run_id.clone(); + ( + conversation_id, + Some(agent_run_id), + Some(provider_harness), + stage, + bucket, + anchor, + serde_json::json!({ + "scoring_eligible": true, + "outcome_linkage_eligible": true, + "outcome_linkage_policy": "exact_run", + }), + ) + } + ( + SkillUsageInjectionKind::FullLoad, + SkillUsageAttribution::BoundedConversation { + conversation_id, + reason, + stage, + bucket, + }, + ) => { + validate_non_empty("skill usage conversation_id", &conversation_id)?; + validate_non_empty("skill usage attribution reason", &reason)?; + let anchor = conversation_id.clone(); + ( + conversation_id, + None, + None, + stage, + bucket, + anchor, + serde_json::json!({ + "scoring_eligible": true, + "outcome_linkage_eligible": true, + "outcome_linkage_policy": "bounded_conversation", + "attribution_reason": reason, + }), + ) + } + ( + SkillUsageInjectionKind::InteractiveStdinUnattributed, + SkillUsageAttribution::InteractiveStdin { + conversation_id, + source_turn_id, + stage, + bucket, + }, + ) => { + validate_non_empty("skill usage conversation_id", &conversation_id)?; + validate_non_empty("skill usage source_turn_id", &source_turn_id)?; + let anchor = source_turn_id.clone(); + ( + conversation_id, + None, + None, + stage, + bucket, + anchor, + serde_json::json!({ + "scoring_eligible": false, + "outcome_linkage_eligible": false, + "outcome_linkage_policy": "none", + "exclusion_reason": "interactive_stdin_has_no_exact_agent_run", + "source_turn_id": source_turn_id, + }), + ) + } + _ => { + return Err(AppError::Validation(format!( + "invalid attribution policy for {} skill usage", + injection_kind.as_str() + ))) + } + }; + + let id = + deterministic_skill_usage_event_id(&project_id, &project_skill_id, injection_kind, &anchor); + Ok(SkillUsageEvent { + id, + project_id, + project_skill_id, + conversation_id: Some(conversation_id), + agent_run_id, + provider_harness, + stage, + bucket, + injection_kind, + outcome_id: None, + metadata_json, + created_at: Utc::now(), + }) +} + +fn deterministic_skill_usage_event_id( + project_id: &ProjectId, + project_skill_id: &ProjectSkillId, + injection_kind: SkillUsageInjectionKind, + anchor: &str, +) -> SkillUsageEventId { + let mut hasher = Sha256::new(); + for component in [ + "ralphx-skill-usage-v1", + project_id.as_str(), + project_skill_id.as_str(), + injection_kind.as_str(), + anchor, + ] { + hasher.update((component.len() as u64).to_be_bytes()); + hasher.update(component.as_bytes()); + } + SkillUsageEventId::from_string(format!("skill-usage-{:x}", hasher.finalize())) +} + pub fn new_empty_task_outcome( project_id: ProjectId, source: TaskOutcomeSource, diff --git a/src-tauri/src/domain/services/learned_skill_substrate_tests.rs b/src-tauri/src/domain/services/learned_skill_substrate_tests.rs index 0fc968778c..0cc4454164 100644 --- a/src-tauri/src/domain/services/learned_skill_substrate_tests.rs +++ b/src-tauri/src/domain/services/learned_skill_substrate_tests.rs @@ -16,12 +16,13 @@ use crate::domain::repositories::{ UpsertTaskOutcomeInput, }; use crate::domain::services::learned_skill_substrate::{ - new_empty_task_outcome, new_skill_usage_event, MemoryToProjectSkillPromotionService, - OutcomeLedgerService, ProjectSkillAgingStatus, ProjectSkillEvidenceLevel, - ProjectSkillImportApplyInput, ProjectSkillImportCandidate, ProjectSkillImportDecision, - ProjectSkillImportPreviewInput, ProjectSkillImportPreviewService, ProjectSkillReportOptions, - ProjectSkillReportService, ProjectSkillService, PromoteMemoryToProjectSkillInput, - SkillUsageService, UpdateProjectSkillContentInput, + new_c2_skill_usage_event, new_empty_task_outcome, new_skill_usage_event, + MemoryToProjectSkillPromotionService, OutcomeLedgerService, ProjectSkillAgingStatus, + ProjectSkillEvidenceLevel, ProjectSkillImportApplyInput, ProjectSkillImportCandidate, + ProjectSkillImportDecision, ProjectSkillImportPreviewInput, ProjectSkillImportPreviewService, + ProjectSkillReportOptions, ProjectSkillReportService, ProjectSkillService, + PromoteMemoryToProjectSkillInput, SkillUsageAttribution, SkillUsageService, + UpdateProjectSkillContentInput, }; use crate::testing::{ InMemoryMemoryEntryRepository, MemoryProjectSkillRepository, MemorySkillUsageEventRepository, @@ -55,6 +56,127 @@ fn staged_skill(project_id: ProjectId) -> ProjectSkill { } } +#[test] +fn c2_usage_policy_builds_deterministic_scoring_and_linkage_metadata() { + let project_id = ProjectId::from_string("project-1".to_string()); + let skill_id = ProjectSkillId::from_string("skill-1"); + let exact = SkillUsageAttribution::ExactRun { + conversation_id: "conversation-1".to_string(), + agent_run_id: "run-1".to_string(), + provider_harness: "codex".to_string(), + stage: Some("execution".to_string()), + bucket: Some("execution".to_string()), + }; + let first = new_c2_skill_usage_event( + project_id.clone(), + skill_id.clone(), + SkillUsageInjectionKind::CompactIndex, + exact.clone(), + ) + .unwrap(); + let retry = new_c2_skill_usage_event( + project_id.clone(), + skill_id.clone(), + SkillUsageInjectionKind::CompactIndex, + exact, + ) + .unwrap(); + let composer = new_c2_skill_usage_event( + project_id.clone(), + skill_id.clone(), + SkillUsageInjectionKind::ComposerDirective, + SkillUsageAttribution::ExactRun { + conversation_id: "conversation-1".to_string(), + agent_run_id: "run-1".to_string(), + provider_harness: "codex".to_string(), + stage: Some("execution".to_string()), + bucket: Some("execution".to_string()), + }, + ) + .unwrap(); + + assert_eq!(first.id, retry.id); + assert_ne!(first.id, composer.id); + assert_eq!(first.agent_run_id.as_deref(), Some("run-1")); + assert_eq!(first.metadata_json["scoring_eligible"], true); + assert_eq!(first.metadata_json["outcome_linkage_eligible"], true); + assert_eq!(first.metadata_json["outcome_linkage_policy"], "exact_run"); + assert!(first.outcome_id.is_none()); + + let bounded = new_c2_skill_usage_event( + project_id.clone(), + skill_id.clone(), + SkillUsageInjectionKind::FullLoad, + SkillUsageAttribution::BoundedConversation { + conversation_id: "conversation-1".to_string(), + reason: "agent_run_unavailable".to_string(), + stage: Some("execution".to_string()), + bucket: Some("execution".to_string()), + }, + ) + .unwrap(); + assert!(bounded.agent_run_id.is_none()); + assert_eq!( + bounded.metadata_json["outcome_linkage_policy"], + "bounded_conversation" + ); + assert_eq!( + bounded.metadata_json["attribution_reason"], + "agent_run_unavailable" + ); + + let stdin = new_c2_skill_usage_event( + project_id, + skill_id, + SkillUsageInjectionKind::InteractiveStdinUnattributed, + SkillUsageAttribution::InteractiveStdin { + conversation_id: "conversation-1".to_string(), + source_turn_id: "message-1".to_string(), + stage: Some("execution".to_string()), + bucket: Some("execution".to_string()), + }, + ) + .unwrap(); + assert!(stdin.agent_run_id.is_none()); + assert_eq!(stdin.metadata_json["scoring_eligible"], false); + assert_eq!(stdin.metadata_json["outcome_linkage_eligible"], false); + assert_eq!( + stdin.metadata_json["exclusion_reason"], + "interactive_stdin_has_no_exact_agent_run" + ); +} + +#[test] +fn c2_usage_policy_rejects_mismatched_or_empty_attribution() { + let project_id = ProjectId::from_string("project-1".to_string()); + let skill_id = ProjectSkillId::from_string("skill-1"); + assert!(new_c2_skill_usage_event( + project_id.clone(), + skill_id.clone(), + SkillUsageInjectionKind::CompactIndex, + SkillUsageAttribution::BoundedConversation { + conversation_id: "conversation-1".to_string(), + reason: "missing".to_string(), + stage: None, + bucket: None, + }, + ) + .is_err()); + assert!(new_c2_skill_usage_event( + project_id, + skill_id, + SkillUsageInjectionKind::FullLoad, + SkillUsageAttribution::ExactRun { + conversation_id: String::new(), + agent_run_id: "run-1".to_string(), + provider_harness: "claude".to_string(), + stage: None, + bucket: None, + }, + ) + .is_err()); +} + fn import_candidate() -> ProjectSkillImportCandidate { ProjectSkillImportCandidate { external_id: Some("manifest-skill-1".to_string()), diff --git a/src-tauri/src/domain/services/mod.rs b/src-tauri/src/domain/services/mod.rs index 930835bf5b..120a4b7df1 100644 --- a/src-tauri/src/domain/services/mod.rs +++ b/src-tauri/src/domain/services/mod.rs @@ -69,13 +69,15 @@ pub use github_service::{ }; pub use index_rewriter::{IndexRewriter, RewriteResult}; pub use learned_skill_substrate::{ - new_empty_task_outcome, new_skill_usage_event, MemoryToProjectSkillPromotionService, + new_c2_skill_usage_event, new_empty_task_outcome, new_skill_usage_event, + MemoryToProjectSkillPromotionService, OutcomeLedgerService, ProjectSkillEvidenceLevel, ProjectSkillImportApplyInput, ProjectSkillImportApplyResult, ProjectSkillImportCandidate, ProjectSkillImportDecision, ProjectSkillImportPreview, ProjectSkillImportPreviewInput, ProjectSkillImportPreviewRow, ProjectSkillImportPreviewService, ProjectSkillReportCard, ProjectSkillReportOptions, ProjectSkillReportService, ProjectSkillService, PromoteMemoryToProjectSkillInput, - PromoteMemoryToProjectSkillResult, SkillUsageService, UpdateProjectSkillContentInput, + PromoteMemoryToProjectSkillResult, SkillUsageAttribution, SkillUsageService, + UpdateProjectSkillContentInput, }; pub use project_skill_resolution::{ import_title_resolution_identity, project_skill_resolution_identities, diff --git a/src-tauri/src/http_server/handlers/learned_skills.rs b/src-tauri/src/http_server/handlers/learned_skills.rs index 83f1fba49d..06dbe99a74 100644 --- a/src-tauri/src/http_server/handlers/learned_skills.rs +++ b/src-tauri/src/http_server/handlers/learned_skills.rs @@ -1,4 +1,8 @@ -use axum::{extract::State, http::StatusCode, Json}; +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + Json, +}; use serde::Deserialize; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -16,19 +20,20 @@ use crate::application::memory_orchestration::{ use crate::application::project_skill_distillation_service::ProjectSkillDistillationSelection; use crate::application::project_skill_export_service::MAX_SKILL_DESCRIPTION_CHARS; use crate::domain::entities::types::ProjectId; -use crate::domain::entities::ChatConversationId; +use crate::domain::entities::{AgentRunId, ChatConversationId}; use crate::domain::entities::{ ChatContextType, MemoryEntryId, ProjectSkill, ProjectSkillId, ProjectSkillLifecycleStatus, - TaskOutcomeClass, TaskOutcomeSource, TaskOutcomeStatus, + SkillUsageInjectionKind, TaskOutcomeClass, TaskOutcomeSource, TaskOutcomeStatus, }; use crate::domain::repositories::{ ProjectSkillListOptions, SkillUsageListOptions, UpsertTaskOutcomeInput, }; use crate::domain::services::{ - new_empty_task_outcome, MemoryToProjectSkillPromotionService, ProjectSkillImportApplyInput, - ProjectSkillImportCandidate, ProjectSkillImportPreview, ProjectSkillImportPreviewInput, - ProjectSkillImportPreviewRow, ProjectSkillImportPreviewService, ProjectSkillReportOptions, - ProjectSkillReportService, ProjectSkillService, PromoteMemoryToProjectSkillInput, + new_c2_skill_usage_event, new_empty_task_outcome, MemoryToProjectSkillPromotionService, + ProjectSkillImportApplyInput, ProjectSkillImportCandidate, ProjectSkillImportPreview, + ProjectSkillImportPreviewInput, ProjectSkillImportPreviewRow, ProjectSkillImportPreviewService, + ProjectSkillReportOptions, ProjectSkillReportService, ProjectSkillService, + PromoteMemoryToProjectSkillInput, SkillUsageAttribution, SkillUsageService, UpdateProjectSkillContentInput, }; use crate::error::{AppError, AppResult}; @@ -321,6 +326,7 @@ pub async fn process_conversation_project_skills( pub async fn get_project_skill( State(state): State, + headers: HeaderMap, scope: ProjectScope, Json(req): Json, ) -> Result, HttpError> { @@ -347,6 +353,7 @@ pub async fn get_project_skill( message: Some("project skill does not belong to the requested project".to_string()), }); } + record_full_load_skill_usage(&state, &headers, &skill).await; return Ok(Json(GetProjectSkillResponse { skill: Some(ProjectSkillResponse::from(skill)), })); @@ -355,6 +362,131 @@ pub async fn get_project_skill( Ok(Json(GetProjectSkillResponse { skill: None })) } +async fn record_full_load_skill_usage( + state: &HttpServerState, + headers: &HeaderMap, + skill: &ProjectSkill, +) { + let attribution = match trusted_full_load_attribution(state, headers, skill).await { + Ok(Some(attribution)) => attribution, + Ok(None) => return, + Err(reason) => { + tracing::warn!( + project_skill_id = skill.id.as_str(), + reason, + "Suppressing learned skill full-load telemetry" + ); + return; + } + }; + let mut event = match new_c2_skill_usage_event( + skill.project_id.clone(), + skill.id.clone(), + SkillUsageInjectionKind::FullLoad, + attribution, + ) { + Ok(event) => event, + Err(error) => { + tracing::warn!(error = %error, "Suppressing invalid learned skill full-load telemetry"); + return; + } + }; + event.metadata_json["source"] = serde_json::json!("get_project_skill"); + let service = SkillUsageService::new(Arc::clone(&state.app_state.skill_usage_event_repo)); + if let Err(error) = service.record_usage_batch(vec![event]).await { + tracing::warn!( + project_skill_id = skill.id.as_str(), + error = %error, + "Failed to record learned skill full-load telemetry" + ); + } +} + +async fn trusted_full_load_attribution( + state: &HttpServerState, + headers: &HeaderMap, + skill: &ProjectSkill, +) -> Result, String> { + let conversation_header = headers.get("x-ralphx-conversation-id"); + let run_header = headers.get("x-ralphx-agent-run-id"); + if conversation_header.is_none() && run_header.is_none() { + return Ok(None); + } + let conversation_id = conversation_header + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "run identity requires a valid conversation identity".to_string())? + .parse::() + .map_err(|_| "conversation identity is malformed".to_string())?; + let conversation = state + .app_state + .chat_conversation_repo + .get_by_id(&conversation_id) + .await + .map_err(|error| format!("conversation identity lookup failed: {error}"))? + .ok_or_else(|| "conversation identity is stale".to_string())?; + let resolved_project_id = + crate::application::chat_service::chat_service_context::resolve_project_id( + conversation.context_type, + &conversation.context_id, + Arc::clone(&state.app_state.task_repo), + Arc::clone(&state.app_state.ideation_session_repo), + Arc::clone(&state.app_state.delegated_session_repo), + ) + .await + .ok_or_else(|| "conversation has no resolvable project authority".to_string())?; + if resolved_project_id != skill.project_id.as_str() { + return Err("conversation belongs to a different project".to_string()); + } + + let Some(run_value) = run_header else { + return Ok(Some(SkillUsageAttribution::BoundedConversation { + conversation_id: conversation_id.as_str().to_string(), + reason: "agent_run_header_absent".to_string(), + stage: Some(skill.stage.clone()), + bucket: Some(skill.bucket.clone()), + })); + }; + let run_id = run_value + .to_str() + .ok() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "run identity is malformed".to_string())? + .parse::() + .map_err(|_| "run identity is malformed".to_string())?; + let run = state + .app_state + .agent_run_repo + .get_by_id(&run_id) + .await + .map_err(|error| format!("run identity lookup failed: {error}"))? + .ok_or_else(|| "run identity is stale".to_string())?; + if run.conversation_id != conversation_id { + return Err("run identity belongs to a different conversation".to_string()); + } + let active_run = state + .app_state + .agent_run_repo + .get_active_for_conversation(&conversation_id) + .await + .map_err(|error| format!("active run lookup failed: {error}"))?; + if active_run.as_ref().map(|active| &active.id) != Some(&run_id) { + return Err("run identity is stale or no longer active".to_string()); + } + let harness = run + .harness + .ok_or_else(|| "run identity has no authoritative harness".to_string())?; + Ok(Some(SkillUsageAttribution::ExactRun { + conversation_id: conversation_id.as_str().to_string(), + agent_run_id: run_id.as_str().to_string(), + provider_harness: harness.to_string(), + stage: Some(skill.stage.clone()), + bucket: Some(skill.bucket.clone()), + })) +} + pub async fn approve_project_skill( State(state): State, scope: ProjectScope, diff --git a/src-tauri/src/http_server/handlers/learned_skills_tests.rs b/src-tauri/src/http_server/handlers/learned_skills_tests.rs index b52c7b9912..aead14abd0 100644 --- a/src-tauri/src/http_server/handlers/learned_skills_tests.rs +++ b/src-tauri/src/http_server/handlers/learned_skills_tests.rs @@ -1,7 +1,11 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use axum::{extract::State, http::StatusCode, Json}; +use axum::{ + extract::State, + http::{HeaderMap, HeaderValue, StatusCode}, + Json, +}; use super::learned_skills::*; use super::*; @@ -12,7 +16,7 @@ use crate::domain::entities::{ SkillUsageInjectionKind, TaskOutcomeClass, TaskOutcomeSource, TaskOutcomeStatus, }; use crate::domain::repositories::{ - ProjectSkillListOptions, TaskOutcomeListOptions, UpsertTaskOutcomeInput, + ProjectSkillListOptions, SkillUsageListOptions, TaskOutcomeListOptions, UpsertTaskOutcomeInput, }; use crate::domain::services::{ new_empty_task_outcome, new_skill_usage_event, ProjectSkillImportCandidate, @@ -357,6 +361,7 @@ async fn get_project_skill_returns_none_and_rejects_cross_project_rows() { let missing = get_project_skill( State(test_state(app_state.clone())), + HeaderMap::new(), ProjectScope(Some(vec![project_id.clone()])), Json(GetProjectSkillRequest { project_id: project_id.as_str().to_string(), @@ -371,6 +376,7 @@ async fn get_project_skill_returns_none_and_rejects_cross_project_rows() { let other_project = ProjectId::from_string("other-project".to_string()); let mismatch = get_project_skill( State(test_state(app_state.clone())), + HeaderMap::new(), ProjectScope(Some(vec![project_id.clone(), other_project.clone()])), Json(GetProjectSkillRequest { project_id: other_project.as_str().to_string(), @@ -383,6 +389,7 @@ async fn get_project_skill_returns_none_and_rejects_cross_project_rows() { let error = get_project_skill( State(test_state(app_state)), + HeaderMap::new(), ProjectScope(Some(vec![other_project])), Json(GetProjectSkillRequest { project_id: project_id.as_str().to_string(), @@ -394,6 +401,144 @@ async fn get_project_skill_returns_none_and_rejects_cross_project_rows() { assert_eq!(error.status, StatusCode::FORBIDDEN); } +#[tokio::test] +async fn c2_get_project_skill_records_trusted_exact_and_bounded_full_loads() { + let app_state = Arc::new(AppState::new_test()); + let project_id = ProjectId::from_string("project-c2-full-load".to_string()); + let skill = staged_skill(project_id.clone()); + app_state + .project_skill_repo + .create(skill.clone()) + .await + .unwrap(); + let conversation = app_state + .chat_conversation_repo + .create(ChatConversation::new_project(project_id.clone())) + .await + .unwrap(); + let mut run = crate::domain::entities::AgentRun::new(conversation.id); + run.harness = Some(crate::domain::agents::AgentHarnessKind::Codex); + let run = app_state.agent_run_repo.create(run).await.unwrap(); + let mut headers = HeaderMap::new(); + headers.insert( + "x-ralphx-conversation-id", + HeaderValue::from_str(&conversation.id.as_str()).unwrap(), + ); + headers.insert( + "x-ralphx-agent-run-id", + HeaderValue::from_str(&run.id.as_str()).unwrap(), + ); + + for _ in 0..2 { + let _ = get_project_skill( + State(test_state(app_state.clone())), + headers.clone(), + ProjectScope(Some(vec![project_id.clone()])), + Json(GetProjectSkillRequest { + project_id: project_id.as_str().to_string(), + project_skill_id: skill.id.as_str().to_string(), + }), + ) + .await + .unwrap(); + } + let usage = app_state + .skill_usage_event_repo + .list_by_project(&project_id, SkillUsageListOptions::default()) + .await + .unwrap(); + assert_eq!(usage.len(), 1, "retry must deduplicate exact full load"); + assert_eq!( + usage[0].agent_run_id.as_deref(), + Some(run.id.as_str().as_str()) + ); + assert_eq!(usage[0].metadata_json["scoring_eligible"], true); + + let bounded_skill = staged_skill(project_id.clone()); + app_state + .project_skill_repo + .create(bounded_skill.clone()) + .await + .unwrap(); + headers.remove("x-ralphx-agent-run-id"); + let _ = get_project_skill( + State(test_state(app_state.clone())), + headers, + ProjectScope(Some(vec![project_id.clone()])), + Json(GetProjectSkillRequest { + project_id: project_id.as_str().to_string(), + project_skill_id: bounded_skill.id.as_str().to_string(), + }), + ) + .await + .unwrap(); + let usage = app_state + .skill_usage_event_repo + .list_by_project(&project_id, SkillUsageListOptions::default()) + .await + .unwrap(); + let bounded = usage + .iter() + .find(|event| event.project_skill_id == bounded_skill.id) + .expect("conversation-only load recorded"); + assert_eq!(bounded.metadata_json["scoring_eligible"], true); + assert_eq!(bounded.agent_run_id, None); + assert_eq!( + bounded.metadata_json["outcome_linkage_policy"], + "bounded_conversation" + ); +} + +#[tokio::test] +async fn c2_get_project_skill_suppresses_forged_stale_and_cross_project_context() { + let app_state = Arc::new(AppState::new_test()); + let project_id = ProjectId::from_string("project-c2-suppressed".to_string()); + let skill = staged_skill(project_id.clone()); + app_state + .project_skill_repo + .create(skill.clone()) + .await + .unwrap(); + let other_project = ProjectId::from_string("project-c2-other".to_string()); + let conversation = app_state + .chat_conversation_repo + .create(ChatConversation::new_project(other_project)) + .await + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert( + "x-ralphx-conversation-id", + HeaderValue::from_str(&conversation.id.as_str()).unwrap(), + ); + headers.insert( + "x-ralphx-agent-run-id", + HeaderValue::from_static("not-a-run-id"), + ); + + let response = get_project_skill( + State(test_state(app_state.clone())), + headers, + ProjectScope(Some(vec![project_id.clone()])), + Json(GetProjectSkillRequest { + project_id: project_id.as_str().to_string(), + project_skill_id: skill.id.as_str().to_string(), + }), + ) + .await + .unwrap() + .0; + assert!( + response.skill.is_some(), + "telemetry rejection must not break the read" + ); + assert!(app_state + .skill_usage_event_repo + .list_by_project(&project_id, SkillUsageListOptions::default()) + .await + .unwrap() + .is_empty()); +} + #[tokio::test] async fn process_conversation_project_skills_rejects_empty_and_missing_conversations() { let app_state = Arc::new(AppState::new_test()); diff --git a/src-tauri/src/infrastructure/agents/claude/mod.rs b/src-tauri/src/infrastructure/agents/claude/mod.rs index 23704b9335..4ea834dc70 100644 --- a/src-tauri/src/infrastructure/agents/claude/mod.rs +++ b/src-tauri/src/infrastructure/agents/claude/mod.rs @@ -1259,6 +1259,7 @@ pub struct SpawnableCommand { stdin_prompt: Option, stdin_transport: SpawnableStdinTransport, prompt_arg_debug_redaction: Option, + injected_skill_names: Vec, persona_injected: bool, persona_injection_skipped_reason: Option<&'static str>, } @@ -1358,6 +1359,7 @@ impl SpawnableCommand { stdin_prompt, stdin_transport, prompt_arg_debug_redaction: None, + injected_skill_names: Vec::new(), persona_injected: false, persona_injection_skipped_reason: None, } @@ -1373,6 +1375,15 @@ impl SpawnableCommand { self } + pub(crate) fn with_injected_skill_names(mut self, injected_skill_names: Vec) -> Self { + self.injected_skill_names = injected_skill_names; + self + } + + pub(crate) fn injected_skill_names(&self) -> &[String] { + &self.injected_skill_names + } + pub(crate) fn persona_injected(&self) -> bool { self.persona_injected } @@ -1534,6 +1545,7 @@ pub fn format_stream_json_input(content: &str) -> String { /// actually appended. Fallback agent-prompt paths deliberately report no injection. struct PromptArgsOutcome { stdin_prompt: Option, + injected_skill_names: Vec, persona_injected: bool, persona_injection_skipped_reason: Option<&'static str>, } @@ -1573,6 +1585,7 @@ fn add_prompt_args( }; let mut persona_injected = false; let mut persona_skip_reason = None; + let mut injected_skill_names = Vec::new(); if let Some(agent_name) = agent { if use_native_agent_flag { cmd.args(["--agent", agent_name]); @@ -1588,13 +1601,13 @@ fn add_prompt_args( persona_block, pre_execution_learned_skills, ); - if let Some((system_prompt, injected_skill_names)) = + if let Some((system_prompt, composed_skill_names)) = prompt_with_internal_skills.as_ref() { - if !injected_skill_names.is_empty() { + if !composed_skill_names.is_empty() { tracing::debug!( agent = agent_name, - skills = ?injected_skill_names, + skills = ?composed_skill_names, "Injected agent prompt with internal skills" ); } @@ -1606,6 +1619,7 @@ fn add_prompt_args( write_agent_system_prompt_temp, ); persona_injected = persona_block.is_some(); + composed_skill_names.clone_into(&mut injected_skill_names); } else if runtime.use_append_system_prompt_file { if let Some(path_str) = prompt_path.to_str() { cmd.args(["--append-system-prompt-file", path_str]); @@ -1699,6 +1713,7 @@ fn add_prompt_args( PromptArgsOutcome { stdin_prompt, + injected_skill_names, persona_injected, persona_injection_skipped_reason: persona_skip_reason, } @@ -1805,7 +1820,8 @@ pub fn build_spawnable_command_with_mcp_runtime_context( .with_persona_injection_outcome( prompt_args.persona_injected, prompt_args.persona_injection_skipped_reason, - )) + ) + .with_injected_skill_names(prompt_args.injected_skill_names)) } #[allow(clippy::too_many_arguments)] @@ -1977,7 +1993,8 @@ fn build_spawnable_profile_command_with_permission_policy_inner( .with_persona_injection_outcome( prompt_args.persona_injected, prompt_args.persona_injection_skipped_reason, - )) + ) + .with_injected_skill_names(prompt_args.injected_skill_names)) } #[cfg(any(test, feature = "test-utils"))] @@ -2053,7 +2070,8 @@ pub fn build_spawnable_command_with_mcp_runtime_context_for_test( .with_persona_injection_outcome( prompt_args.persona_injected, prompt_args.persona_injection_skipped_reason, - )) + ) + .with_injected_skill_names(prompt_args.injected_skill_names)) } #[cfg(any(test, feature = "test-utils"))] diff --git a/src-tauri/src/infrastructure/agents/claude/mod_tests.rs b/src-tauri/src/infrastructure/agents/claude/mod_tests.rs index c3a3b0598d..91a24c6669 100644 --- a/src-tauri/src/infrastructure/agents/claude/mod_tests.rs +++ b/src-tauri/src/infrastructure/agents/claude/mod_tests.rs @@ -1586,6 +1586,58 @@ role: project_chat assert_eq!(injected_names, vec!["learned:skill-planning"]); } +#[test] +fn c2_spawnable_carries_exact_pre_execution_skill_names() { + let (_dir, root, plugin_dir) = make_temp_project_plugin_dir(); + let agent_root = root.join("agents/ralphx-chat-project"); + std::fs::create_dir_all(agent_root.join("shared")).expect("create shared prompt dir"); + std::fs::write( + agent_root.join("agent.yaml"), + "name: ralphx-chat-project\nrole: project_chat\n", + ) + .expect("write shared definition"); + std::fs::write(agent_root.join("shared/prompt.md"), "Project chat prompt") + .expect("write shared prompt"); + let context = PreExecutionLearnedSkillContext { + request: LearnedSkillMultiSelectionRequest { + project_id: "project-1".to_string(), + caller_surface: "ralphx-chat-project".to_string(), + stages: vec![LearnedSkillStage::Planning], + buckets: vec![LearnedSkillBucket::Planning], + touched_paths: Vec::new(), + max_skills: 4, + }, + available_skills: vec![learned_skill("skill-planning", "project-1") + .with_caller_surfaces(vec!["ralphx-chat-project"]) + .with_stages(vec![LearnedSkillStage::Planning]) + .with_buckets(vec![LearnedSkillBucket::Planning])], + max_total_chars: 6_000, + max_guidance_chars: 400, + }; + + let spawnable = build_spawnable_profile_command_with_permission_policy_for_test( + Path::new("/fake/claude"), + &plugin_dir, + "Plan the domain change.", + Some("ralphx:ralphx-chat-project"), + None, + Some("Methodical planner"), + None, + root.as_path(), + false, + None, + None, + None, + Some(&context), + ClaudePermissionPolicy::InheritConfigured, + ClaudePromptDelivery::NonInteractive, + ) + .expect("build spawnable"); + + assert_eq!(spawnable.injected_skill_names(), ["learned:skill-planning"]); + assert!(spawnable.persona_injected()); +} + #[test] fn build_spawnable_command_prompt_file_uses_generated_claude_prompt() { let (_dir, root, plugin_dir) = make_temp_project_plugin_dir(); diff --git a/src-tauri/src/infrastructure/agents/codex/mod.rs b/src-tauri/src/infrastructure/agents/codex/mod.rs index 4d67662e57..a06ecc0a1b 100644 --- a/src-tauri/src/infrastructure/agents/codex/mod.rs +++ b/src-tauri/src/infrastructure/agents/codex/mod.rs @@ -482,6 +482,8 @@ pub fn compose_codex_prompt_for_profile( pub struct CodexPromptComposition { /// Prompt delivered to the Codex CLI. pub prompt: String, + /// Exact structured skill names injected while composing `prompt`. + pub injected_skill_names: Vec, /// Whether the resolved persona overlay is present in `prompt`. pub persona_injected: bool, /// Body-free reason when a requested persona overlay could not be composed. @@ -587,6 +589,7 @@ fn compose_codex_prompt_for_profile_with_context( let Some(plugin_dir) = plugin_dir else { return CodexPromptComposition { prompt: prompt.to_string(), + injected_skill_names: Vec::new(), persona_injected: false, persona_injection_skipped_reason: persona_block .map(|_| "codex_plugin_dir_unavailable"), @@ -595,6 +598,7 @@ fn compose_codex_prompt_for_profile_with_context( let Some(agent_name) = agent_name else { return CodexPromptComposition { prompt: prompt.to_string(), + injected_skill_names: Vec::new(), persona_injected: false, persona_injection_skipped_reason: persona_block.map(|_| "codex_agent_unavailable"), }; @@ -610,6 +614,7 @@ fn compose_codex_prompt_for_profile_with_context( let Some(system_prompt) = system_prompt else { return CodexPromptComposition { prompt: prompt.to_string(), + injected_skill_names: Vec::new(), persona_injected: false, persona_injection_skipped_reason: persona_block .map(|_| "codex_agent_prompt_unavailable"), @@ -619,20 +624,17 @@ fn compose_codex_prompt_for_profile_with_context( let system_prompt = super::persona_overlay::apply_persona_overlay(system_prompt, persona_block); let runtime_profile_context = render_agent_runtime_profile_context(&project_root, agent_name, agent_profile); - let system_prompt = match inject_internal_skills_into_system_prompt_for_profile( + let injection = match inject_internal_skills_into_system_prompt_for_profile( &project_root, agent_name, agent_profile, &system_prompt, prompt, ) { - Ok(injection) => { - inject_pre_execution_learned_skills_into_existing_injection( - injection, - pre_execution_learned_skills, - ) - .system_prompt - } + Ok(injection) => inject_pre_execution_learned_skills_into_existing_injection( + injection, + pre_execution_learned_skills, + ), Err(error) => { warn!( agent = agent_name, @@ -646,9 +648,10 @@ fn compose_codex_prompt_for_profile_with_context( }, pre_execution_learned_skills, ) - .system_prompt } }; + let injected_skill_names = injection.injected_skill_names; + let system_prompt = injection.system_prompt; let system_prompt = match runtime_profile_context { Some(context) => format!("{system_prompt}\n\n{context}"), None => system_prompt, @@ -658,6 +661,7 @@ fn compose_codex_prompt_for_profile_with_context( prompt: format!( "\n{system_prompt}\n\n\n{prompt}" ), + injected_skill_names, persona_injected, persona_injection_skipped_reason: None, } diff --git a/src-tauri/src/infrastructure/agents/codex/mod_tests.rs b/src-tauri/src/infrastructure/agents/codex/mod_tests.rs index e0ffd34658..65d3c96eeb 100644 --- a/src-tauri/src/infrastructure/agents/codex/mod_tests.rs +++ b/src-tauri/src/infrastructure/agents/codex/mod_tests.rs @@ -991,6 +991,21 @@ role: project_chat assert!(composed.contains("")); assert!(composed.contains("skill-planning")); assert!(!composed.contains("skill-review")); + assert_eq!(composition.injected_skill_names, ["learned:skill-planning"]); +} + +#[test] +fn c2_codex_fallback_has_no_stale_injected_names() { + let composition = compose_codex_prompt_for_profile_with_learned_skills_and_outcome( + "Plan the domain change.", + None, + Some("ralphx-chat-project"), + None, + None, + None, + ); + + assert!(composition.injected_skill_names.is_empty()); } #[test] diff --git a/src-tauri/src/infrastructure/memory/memory_learned_skill_repos.rs b/src-tauri/src/infrastructure/memory/memory_learned_skill_repos.rs index ae6d0a3384..904d75b8b4 100644 --- a/src-tauri/src/infrastructure/memory/memory_learned_skill_repos.rs +++ b/src-tauri/src/infrastructure/memory/memory_learned_skill_repos.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::RwLock; use async_trait::async_trait; @@ -451,19 +453,48 @@ fn validate_memory_companion( #[derive(Default)] pub struct MemorySkillUsageEventRepository { rows: RwLock>, + fail_next_batch: AtomicBool, } impl MemorySkillUsageEventRepository { pub fn new() -> Self { Self::default() } + + #[cfg(any(test, feature = "test-utils"))] + pub fn fail_next_batch_for_test(&self) { + self.fail_next_batch.store(true, Ordering::SeqCst); + } } #[async_trait] impl SkillUsageEventRepository for MemorySkillUsageEventRepository { async fn record(&self, event: SkillUsageEvent) -> AppResult { - self.rows.write().unwrap().push(event.clone()); - Ok(event) + let mut saved = self.record_batch(vec![event]).await?; + saved.pop().ok_or_else(|| { + AppError::Database("skill usage batch unexpectedly returned no event".to_string()) + }) + } + + async fn record_batch(&self, events: Vec) -> AppResult> { + if self.fail_next_batch.swap(false, Ordering::SeqCst) { + return Err(AppError::Database( + "injected memory skill usage batch failure".to_string(), + )); + } + + let mut rows = self.rows.write().unwrap(); + let mut known_ids = rows + .iter() + .map(|row| row.id.as_str().to_string()) + .collect::>(); + let saved = events; + for event in &saved { + if known_ids.insert(event.id.as_str().to_string()) { + rows.push(event.clone()); + } + } + Ok(saved) } async fn list_by_project( diff --git a/src-tauri/src/infrastructure/memory/memory_learned_skill_repos_tests.rs b/src-tauri/src/infrastructure/memory/memory_learned_skill_repos_tests.rs index c997830bdb..9ce55c0fa8 100644 --- a/src-tauri/src/infrastructure/memory/memory_learned_skill_repos_tests.rs +++ b/src-tauri/src/infrastructure/memory/memory_learned_skill_repos_tests.rs @@ -1,21 +1,79 @@ use chrono::{Duration, Utc}; use serde_json::json; -use super::{MemoryProjectSkillRepository, MemoryTaskOutcomeRepository}; +use super::{ + MemoryProjectSkillRepository, MemorySkillUsageEventRepository, MemoryTaskOutcomeRepository, +}; use crate::domain::entities::{ - ProjectId, ProjectSkill, ProjectSkillId, ProjectSkillLifecycleStatus, TaskOutcome, - TaskOutcomeClass, TaskOutcomeId, TaskOutcomeSource, TaskOutcomeStatus, + ProjectId, ProjectSkill, ProjectSkillId, ProjectSkillLifecycleStatus, SkillUsageInjectionKind, + TaskOutcome, TaskOutcomeClass, TaskOutcomeId, TaskOutcomeSource, TaskOutcomeStatus, }; use crate::domain::repositories::{ canonical_terminal_pr_source_ref_id, ProjectSkillMatchedMutation, ProjectSkillRepository, ProjectSkillResolutionCommand, ProjectSkillResolutionIntent, ProjectSkillResolutionOutcome, - ProjectSkillStagingPolicy, TaskOutcomeRepository, UpsertTaskOutcomeInput, - AGENT_WORKSPACE_PR_OUTCOME_SOURCE, TERMINAL_PR_SOURCE_REF_KIND, WORKSPACE_PR_CLOSED_CLASS, - WORKSPACE_PR_FAILED_CLASS, WORKSPACE_PR_MERGED_CLASS, WORKSPACE_PR_MERGED_CLEAN_CLASS, + ProjectSkillStagingPolicy, SkillUsageEventRepository, SkillUsageListOptions, + TaskOutcomeRepository, UpsertTaskOutcomeInput, AGENT_WORKSPACE_PR_OUTCOME_SOURCE, + TERMINAL_PR_SOURCE_REF_KIND, WORKSPACE_PR_CLOSED_CLASS, WORKSPACE_PR_FAILED_CLASS, + WORKSPACE_PR_MERGED_CLASS, WORKSPACE_PR_MERGED_CLEAN_CLASS, WORKSPACE_PR_MERGED_WITH_FOLLOWUPS_CLASS, WORKSPACE_PR_TERMINAL_CLASS, }; +use crate::domain::services::learned_skill_substrate::{ + new_c2_skill_usage_event, SkillUsageAttribution, +}; use crate::domain::services::project_skill_resolution::import_title_resolution_identity; +#[tokio::test] +async fn c2_memory_usage_batch_is_idempotent_and_failure_atomic() { + let repo = MemorySkillUsageEventRepository::new(); + let project_id = ProjectId::from_string("project-1".to_string()); + let event = new_c2_skill_usage_event( + project_id.clone(), + ProjectSkillId::from_string("skill-1"), + SkillUsageInjectionKind::CompactIndex, + SkillUsageAttribution::ExactRun { + conversation_id: "conversation-1".to_string(), + agent_run_id: "run-1".to_string(), + provider_harness: "claude".to_string(), + stage: Some("execution".to_string()), + bucket: Some("execution".to_string()), + }, + ) + .unwrap(); + + repo.record_batch(vec![event.clone(), event]).await.unwrap(); + assert_eq!( + repo.list_by_project(&project_id, SkillUsageListOptions::default()) + .await + .unwrap() + .len(), + 1 + ); + + repo.fail_next_batch_for_test(); + let second = new_c2_skill_usage_event( + project_id.clone(), + ProjectSkillId::from_string("skill-2"), + SkillUsageInjectionKind::ComposerDirective, + SkillUsageAttribution::ExactRun { + conversation_id: "conversation-1".to_string(), + agent_run_id: "run-2".to_string(), + provider_harness: "codex".to_string(), + stage: Some("execution".to_string()), + bucket: Some("execution".to_string()), + }, + ) + .unwrap(); + assert!(repo.record_batch(vec![second]).await.is_err()); + assert_eq!( + repo.list_by_project(&project_id, SkillUsageListOptions::default()) + .await + .unwrap() + .len(), + 1, + "failed memory batch must not partially mutate rows" + ); +} + fn terminal_outcome(outcome_class: &str, evidence: &str) -> TaskOutcome { let now = Utc::now(); TaskOutcome { diff --git a/src-tauri/src/infrastructure/sqlite/sqlite_learned_skill_repos.rs b/src-tauri/src/infrastructure/sqlite/sqlite_learned_skill_repos.rs index 67193cd462..96a47d8911 100644 --- a/src-tauri/src/infrastructure/sqlite/sqlite_learned_skill_repos.rs +++ b/src-tauri/src/infrastructure/sqlite/sqlite_learned_skill_repos.rs @@ -585,33 +585,49 @@ impl ProjectSkillRepository for SqliteProjectSkillRepository { #[async_trait] impl SkillUsageEventRepository for SqliteSkillUsageEventRepository { async fn record(&self, event: SkillUsageEvent) -> AppResult { - let metadata_json = serde_json::to_string(&event.metadata_json) - .map_err(|error| AppError::Database(error.to_string()))?; - let saved = event.clone(); + let mut saved = self.record_batch(vec![event]).await?; + saved.pop().ok_or_else(|| { + AppError::Database("skill usage batch unexpectedly returned no event".to_string()) + }) + } + + async fn record_batch(&self, events: Vec) -> AppResult> { + let serialized = events + .iter() + .map(|event| { + serde_json::to_string(&event.metadata_json) + .map(|metadata_json| (event.clone(), metadata_json)) + .map_err(|error| AppError::Database(error.to_string())) + }) + .collect::>>()?; + let saved = events; self.db - .run(move |conn| { - conn.execute( - "INSERT INTO skill_usage_events ( + .run_transaction(move |conn| { + for (event, metadata_json) in serialized { + conn.execute( + "INSERT INTO skill_usage_events ( id, project_id, project_skill_id, conversation_id, agent_run_id, provider_harness, stage, bucket, injection_kind, outcome_id, metadata_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - rusqlite::params![ - saved.id.as_str(), - saved.project_id.as_str(), - saved.project_skill_id.as_str(), - saved.conversation_id, - saved.agent_run_id, - saved.provider_harness, - saved.stage, - saved.bucket, - saved.injection_kind.to_string(), - saved.outcome_id.as_ref().map(|id| id.as_str().to_string()), - metadata_json, - saved.created_at.to_rfc3339(), - ], - )?; - Ok(event) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + ON CONFLICT(id) DO NOTHING", + rusqlite::params![ + event.id.as_str(), + event.project_id.as_str(), + event.project_skill_id.as_str(), + event.conversation_id, + event.agent_run_id, + event.provider_harness, + event.stage, + event.bucket, + event.injection_kind.to_string(), + event.outcome_id.as_ref().map(|id| id.as_str().to_string()), + metadata_json, + event.created_at.to_rfc3339(), + ], + )?; + } + Ok(saved) }) .await } diff --git a/src-tauri/src/infrastructure/sqlite/sqlite_learned_skill_repos_tests.rs b/src-tauri/src/infrastructure/sqlite/sqlite_learned_skill_repos_tests.rs index 7a09df1ad7..ec57108010 100644 --- a/src-tauri/src/infrastructure/sqlite/sqlite_learned_skill_repos_tests.rs +++ b/src-tauri/src/infrastructure/sqlite/sqlite_learned_skill_repos_tests.rs @@ -21,6 +21,9 @@ use crate::domain::repositories::{ ProjectSkillResolutionOutcome, ProjectSkillStagingPolicy, SkillUsageEventRepository, SkillUsageListOptions, TaskOutcomeListOptions, TaskOutcomeRepository, UpsertTaskOutcomeInput, }; +use crate::domain::services::learned_skill_substrate::{ + new_c2_skill_usage_event, SkillUsageAttribution, +}; use crate::domain::services::project_skill_resolution::import_title_resolution_identity; use crate::infrastructure::sqlite::run_migrations; @@ -520,6 +523,86 @@ async fn project_skill_lifecycle_and_usage_round_trip() { assert_eq!(usage.len(), 1); } +#[tokio::test] +async fn c2_sqlite_usage_batch_dedupes_and_rolls_back_on_failure() { + let conn = shared_test_connection(); + let skill_repo = SqliteProjectSkillRepository::from_shared(Arc::clone(&conn)); + let usage_repo = SqliteSkillUsageEventRepository::from_shared(conn); + let project_id = ProjectId::from_string("project-1".to_string()); + let skill = project_skill( + "C2 Usage Skill", + "execution", + "execution", + ProjectSkillLifecycleStatus::Approved, + Vec::new(), + ); + let skill_id = skill.id.clone(); + skill_repo.create(skill).await.unwrap(); + let event = new_c2_skill_usage_event( + project_id.clone(), + skill_id.clone(), + SkillUsageInjectionKind::CompactIndex, + SkillUsageAttribution::ExactRun { + conversation_id: "conversation-1".to_string(), + agent_run_id: "run-1".to_string(), + provider_harness: "claude".to_string(), + stage: Some("execution".to_string()), + bucket: Some("execution".to_string()), + }, + ) + .unwrap(); + + usage_repo + .record_batch(vec![event.clone(), event]) + .await + .unwrap(); + assert_eq!( + usage_repo + .list_by_project(&project_id, SkillUsageListOptions::default()) + .await + .unwrap() + .len(), + 1 + ); + + let valid = new_c2_skill_usage_event( + project_id.clone(), + skill_id, + SkillUsageInjectionKind::ComposerDirective, + SkillUsageAttribution::ExactRun { + conversation_id: "conversation-1".to_string(), + agent_run_id: "run-2".to_string(), + provider_harness: "codex".to_string(), + stage: Some("execution".to_string()), + bucket: Some("execution".to_string()), + }, + ) + .unwrap(); + let invalid = new_c2_skill_usage_event( + project_id.clone(), + ProjectSkillId::from_string("missing-skill"), + SkillUsageInjectionKind::ComposerDirective, + SkillUsageAttribution::ExactRun { + conversation_id: "conversation-1".to_string(), + agent_run_id: "run-3".to_string(), + provider_harness: "codex".to_string(), + stage: Some("execution".to_string()), + bucket: Some("execution".to_string()), + }, + ) + .unwrap(); + assert!(usage_repo.record_batch(vec![valid, invalid]).await.is_err()); + assert_eq!( + usage_repo + .list_by_project(&project_id, SkillUsageListOptions::default()) + .await + .unwrap() + .len(), + 1, + "failed SQLite batch must roll back earlier inserts" + ); +} + #[tokio::test] async fn project_skill_filters_order_archived_and_missing_updates() { let conn = shared_test_connection();