diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index dc4a3f8df130..c2b4db8434ec 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -78,6 +78,7 @@ import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; +import * as IdentityService from "../src/identity/IdentityService.ts"; const decodeCodexSettings = Schema.decodeEffect(CodexSettings); @@ -381,6 +382,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(persistenceLayer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(IdentityService.layerWithPeople([])), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/identity/agentAttribution.test.ts b/apps/server/src/identity/agentAttribution.test.ts new file mode 100644 index 000000000000..0cc26d608a95 --- /dev/null +++ b/apps/server/src/identity/agentAttribution.test.ts @@ -0,0 +1,36 @@ +import { IdentityUsername, PersonId, type SourceRef } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { withAgentIdentityAttribution } from "./agentAttribution.ts"; + +const people = [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + github: { login: "patroza", id: "42661" }, + }, +] as const; + +describe("withAgentIdentityAttribution", () => { + it.each(["desktop", "web", "discord", "jira", "github"] as const)( + "applies the mapped identity to %s turns", + (channel) => { + const source: SourceRef = { + channel, + personId: PersonId.make("patroza"), + username: IdentityUsername.make("patroza"), + }; + const result = withAgentIdentityAttribution({ message: "make the change", source, people }); + + expect(result).toContain("The server identity map attributes this turn to patroza"); + expect(result).toContain( + "Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>", + ); + }, + ); + + it("leaves unattributed turns unchanged", () => { + expect(withAgentIdentityAttribution({ message: "hello", people })).toBe("hello"); + }); +}); diff --git a/apps/server/src/identity/agentAttribution.ts b/apps/server/src/identity/agentAttribution.ts new file mode 100644 index 000000000000..b1ac03b3ded7 --- /dev/null +++ b/apps/server/src/identity/agentAttribution.ts @@ -0,0 +1,22 @@ +import type { SourceRef } from "@t3tools/contracts"; +import { + findPersonByPersonId, + formatCoAuthoredByTrailer, + type IdentityMapPerson, +} from "@t3tools/shared/identityMap"; + +/** Add trusted commit attribution to an agent turn, regardless of its channel. */ +export function withAgentIdentityAttribution(input: { + readonly message: string; + readonly source?: SourceRef | undefined; + readonly people: ReadonlyArray; +}): string { + const personId = input.source?.personId; + if (personId === undefined) return input.message; + const person = findPersonByPersonId(input.people, personId); + if (person === null) return input.message; + const trailer = formatCoAuthoredByTrailer(person); + if (trailer === null) return input.message; + + return `${input.message}\n\n\nThe server identity map attributes this turn to ${person.username}. Every git commit created for this work must include this exact trailer after a blank line:\n${trailer}\nKeep the environment's default author and committer.\n`; +} diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 0255c80eb948..ec19e282431a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -16,7 +16,9 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, EventId, + IdentityUsername, MessageId, + PersonId, ProjectId, ThreadId, TurnId, @@ -68,6 +70,8 @@ import * as Clock from "effect/Clock"; import { ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; +import * as IdentityService from "../../identity/IdentityService.ts"; +import type { IdentityMapPerson } from "@t3tools/shared/identityMap"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); @@ -168,6 +172,7 @@ describe("ProviderCommandReactor", () => { session: ProviderSession, ) => Effect.Effect; readonly interruptTurnEffect?: ProviderServiceShape["interruptTurn"]; + readonly identityPeople?: ReadonlyArray; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -438,6 +443,7 @@ describe("ProviderCommandReactor", () => { }), ), Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(IdentityService.layerWithPeople(input?.identityPeople ?? [])), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(persistenceLayer), @@ -649,6 +655,45 @@ describe("ProviderCommandReactor", () => { expect(harness.sendTurn).toHaveBeenCalledTimes(1); }); + it("sends desktop turns with identity-map commit attribution", async () => { + const harness = await createHarness({ + identityPeople: [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + github: { login: "patroza", id: "42661" }, + }, + ], + }); + await harness.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-attributed-turn"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("attributed-user-message"), + role: "user", + text: "ship this", + attachments: [], + }, + source: { + channel: "desktop", + personId: PersonId.make("patroza"), + username: IdentityUsername.make("patroza"), + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + input: expect.stringContaining( + "Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>", + ), + }); + }); + it("continues an interrupted running turn without replaying its user message", async () => { const modelSelection: ModelSelection = { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b48e42b97db6..e75ce7485f8e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -67,6 +67,8 @@ import { } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { IdentityService } from "../../identity/IdentityService.ts"; +import { withAgentIdentityAttribution } from "../../identity/agentAttribution.ts"; const PROVIDER_CONTROL_TIMEOUT = Duration.seconds(5); const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); @@ -285,6 +287,7 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const identityService = yield* IdentityService; const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); @@ -1148,9 +1151,15 @@ const make = Effect.gen(function* () { ), ); + const identityPeople = yield* identityService.listMapPeople(); + const attributedMessageText = withAgentIdentityAttribution({ + message: message.text, + source: message.source, + people: identityPeople, + }); const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, - messageText: message.text, + messageText: attributedMessageText, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } diff --git a/packages/shared/src/identityMap.ts b/packages/shared/src/identityMap.ts index 9ba48aedf29b..678d39962409 100644 --- a/packages/shared/src/identityMap.ts +++ b/packages/shared/src/identityMap.ts @@ -253,6 +253,35 @@ export function findPersonByDiscordId( return people.find((person) => person.discord?.id === id) ?? null; } +/** Resolve the canonical person stamped onto interactive and integration turns. */ +export function findPersonByPersonId( + people: ReadonlyArray, + personId: string, +): IdentityMapPerson | null { + const normalized = personId.trim().toLowerCase(); + if (normalized.length === 0) return null; + return people.find((person) => person.personId.toLowerCase() === normalized) ?? null; +} + +/** Full Git trailer for a mapped person, when GitHub attribution is configured. */ +export function formatCoAuthoredByTrailer(person: IdentityMapPerson): string | null { + const github = person.github; + if (github === undefined) return null; + const explicitEmail = github.email?.trim(); + const id = github.id?.trim(); + const login = github.login.trim(); + const email = + explicitEmail && explicitEmail.length > 0 + ? explicitEmail + : id && id.length > 0 && login.length > 0 + ? `${id}+${login}@users.noreply.github.com` + : null; + if (email === null) return null; + const rawName = github.name ?? person.name ?? person.username; + const name = rawName.replace(/[\r\n]+/gu, " ").trim(); + return name.length > 0 ? `Co-authored-by: ${name} <${email}>` : null; +} + export function findPersonByGithubLogin( people: ReadonlyArray, login: string,