diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index d47fb11fe6a2b..ff5bfe229ffe0 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -491,6 +491,20 @@ describe("orchestrator MCP toolkit", () => { expect(finalProjection.runs.find((run) => run.id === queuedFollowup.runId)?.status).toBe( "completed", ); + for (const body of [delegatedPrompt, cancellationPrompt, queuedFollowupPrompt]) { + expect(finalProjection.messages.find((message) => message.text === body)).toMatchObject( + { + text: body, + createdBy: "agent", + senderThreadId: parentThreadId, + }, + ); + } + expect( + finalProjection.messages.some((message) => + message.text.startsWith("Agent-authored message"), + ), + ).toBe(false); expect( finalProjection.turnItems.find( (item) => diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 37350b4925cf9..4ee3920c02586 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -38,6 +38,7 @@ import * as Exit from "effect/Exit"; import { TestClock } from "effect/testing"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -2230,6 +2231,109 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + it.effect("keeps an agent /hello on the native command after the sender line", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("command.list", ""), + reply("command.list", { location: { directory: WORK }, data: [{ name: "hello" }] }), + out("message.list", { sessionID: SESSION, order: "desc", limit: "1" }), + reply("message.list", { + data: [ + { id: "msg_idle_before", time: { created: 1 }, type: "idle", outcome: "succeeded" }, + ], + cursor: {}, + }), + out("session.command", { sessionID: SESSION, name: "hello", text: "WORLD" }), + reply("session.command", null), + { type: "runtime_exit", status: "success" } as const, + out("event.subscribe"), + out("session.active"), + replyData("session.active", {}), + out("message.list", { sessionID: SESSION, order: "desc", limit: "50" }), + reply("message.list", { + data: [ + { id: "msg_idle_cmd", time: { created: 4 }, type: "idle", outcome: "succeeded" }, + { + id: "msg_assistant_cmd", + time: { created: 3 }, + type: "assistant", + agent: "build", + model: { id: "big-pickle", providerID: "opencode", variant: "default" }, + content: [{ type: "text", text: "HELLO WORLD" }], + finish: "stop", + }, + { + id: "msg_user_cmd", + time: { created: 2 }, + text: "Reply with exactly: HELLO WORLD", + type: "user", + }, + { id: "msg_idle_before", time: { created: 1 }, type: "idle", outcome: "succeeded" }, + ], + cursor: {}, + }), + ]); + const text = yield* providerTextForTurn({ + text: "/hello WORLD", + createdBy: "agent", + senderThreadId: ThreadId.make("thread:child-review"), + readSenderTitle: () => Effect.die("commands do not look up a sender"), + readReceiverSubagents: () => Effect.die("commands do not look up a sender"), + }); + assert.equal(text, "/hello WORLD"); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.startTurn({ + ...turnInput(thread), + message: { ...turnInput(thread).message, text }, + }); + const collected = yield* Fiber.join(events); + assert.deepInclude(collected.at(-1), { type: "turn.terminal", status: "completed" }); + }).pipe(Effect.scoped), + ); + + it.effect("keeps an agent /hello with a file on the raw prompt", () => + Effect.gen(function* () { + const prompts: Array<{ readonly text?: string }> = []; + const notes = ChatFileAttachment.make({ + type: "file", + id: ChatAttachmentId.make("thread-opencode2-adapter-45678901-1234-1234-1234-123456789abc"), + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 4, + }); + const { runtime, thread } = yield* resumed( + [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ], + { onPrompt: (input) => prompts.push(input as { readonly text?: string }) }, + ); + const text = yield* providerTextForTurn({ + text: "/hello WORLD", + createdBy: "agent", + senderThreadId: ThreadId.make("thread:child-review"), + readSenderTitle: () => Effect.die("commands do not look up a sender"), + readReceiverSubagents: () => Effect.die("commands do not look up a sender"), + }); + assert.equal(text, "/hello WORLD"); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + const input = turnInput(thread); + yield* runtime.startTurn({ + ...input, + message: { ...input.message, text, attachments: [notes] }, + }); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + assert.equal(prompts.length, 1); + assert.match(prompts[0]?.text ?? "", /^\/hello WORLD/); + assert.notMatch(prompts[0]?.text ?? "", /Agent-authored/); + }).pipe(Effect.scoped), + ); + it.effect("keeps a turn still running after a reconnect open for its next events", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 4837c4a2ac1c6..adfec83e1eea6 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -1,7 +1,9 @@ import { assert, it } from "@effect/vitest"; import { + MessageId, type ModelSelection, NodeId, + type OrchestrationV2ConversationMessage, type OrchestrationV2ProviderThread, type OrchestrationV2ThreadProjection, ProjectId, @@ -332,3 +334,265 @@ it.effect( assert.equal(interrupted?.nativeThreadRef?.nativeId, "native-thread:restart-session"); }), ); + +const steerBody = "Please merge the PR now."; +const steerChildThreadId = ThreadId.make("thread:child-review"); + +function dyingProjectionStore( + implemented: Partial, +): ProjectionStore.ProjectionStoreV2["Service"] { + return new Proxy(implemented, { + get(target, property, receiver) { + if (Reflect.has(target, property)) return Reflect.get(target, property, receiver); + return () => Effect.die(`unused ${String(property)}`); + }, + }) as ProjectionStore.ProjectionStoreV2["Service"]; +} + +function steerProviderText(input: { + readonly createdBy: OrchestrationV2ConversationMessage["createdBy"]; + readonly creationSource: OrchestrationV2ConversationMessage["creationSource"]; + readonly senderThreadId?: ThreadId; + readonly senderTitle?: string; + readonly senderLookupFails?: boolean; + readonly text?: string; + readonly attachments?: OrchestrationV2ConversationMessage["attachments"]; + readonly subagents?: ReadonlyArray<{ + readonly origin: "app_owned" | "provider_native"; + readonly childThreadId: ThreadId | null; + }>; + readonly subagentLookupFails?: boolean; +}) { + return Effect.gen(function* () { + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:steer-provenance"); + const providerSessionId = ProviderSessionId.make("provider-session:steer-provenance"); + const providerThreadId = ProviderThreadId.make("provider-thread:steer-provenance"); + const providerTurnId = ProviderTurnId.make("provider-turn:steer-provenance"); + const messageId = MessageId.make("message:steer-provenance"); + const providerThread: OrchestrationV2ProviderThread = { + id: providerThreadId, + driver, + providerInstanceId, + providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { + driver, + nativeId: "native-thread:steer-provenance", + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const message: OrchestrationV2ConversationMessage = { + id: messageId, + threadId, + runId: RunId.make("run:steer-provenance"), + nodeId: NodeId.make("node:steer-provenance"), + role: "user", + text: input.text ?? steerBody, + attachments: input.attachments ?? [], + streaming: false, + createdBy: input.createdBy, + creationSource: input.creationSource, + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + createdAt: now, + updatedAt: now, + }; + const steered = yield* Ref.make(undefined); + const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { + instanceId: providerInstanceId, + driver, + providerSessionId, + providerSession: { + id: providerSessionId, + driver, + providerInstanceId, + status: "running", + cwd: "/workspace", + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.empty, + ensureThread: () => Effect.die("unused ensureThread"), + resumeThread: () => Effect.die("unused resumeThread"), + startTurn: () => Effect.die("unused startTurn"), + steerTurn: (request) => Ref.set(steered, request.message.text), + interruptTurn: () => Effect.die("unused interruptTurn"), + respondToRuntimeRequest: () => Effect.die("unused respondToRuntimeRequest"), + readThreadSnapshot: () => Effect.die("unused readThreadSnapshot"), + rollbackThread: () => Effect.die("unused rollbackThread"), + forkThread: () => Effect.die("unused forkThread"), + }; + const layer = ProviderTurnControlService.layer.pipe( + Layer.provide( + Layer.merge( + Layer.succeed( + ProjectionStore.ProjectionStoreV2, + ProjectionStore.ProjectionStoreV2.of( + dyingProjectionStore({ + getProviderControlContext: () => + Effect.succeed({ + providerThread, + providerTurn: { + id: providerTurnId, + providerThreadId, + nodeId: NodeId.make("node:steer-provenance"), + runAttemptId: RunAttemptId.make("run-attempt:steer-provenance"), + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + attempt: undefined, + message, + run: { + id: RunId.make("run:steer-provenance"), + } as OrchestrationV2ThreadProjection["runs"][number], + }), + getThread: (requestedThreadId) => + input.createdBy === "user" + ? Effect.die("user messages do not look up a sender") + : input.senderLookupFails === true + ? Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId: requestedThreadId, + cause: "sender lookup failed", + }), + ) + : Effect.succeed({ + title: input.senderTitle ?? "Sender", + } as never), + getThreadRecords: () => + input.createdBy === "user" + ? Effect.die("user messages do not look up a sender") + : input.subagentLookupFails === true + ? Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId, + cause: "subagent lookup failed", + }), + ) + : Effect.succeed({ + subagents: input.subagents ?? [], + } as never), + }), + ), + ), + Layer.succeed( + ProviderSessionManager.ProviderSessionManagerV2, + ProviderSessionManager.ProviderSessionManagerV2.of({ + shutdown: Effect.void, + open: () => Effect.die("unused open"), + get: () => Effect.succeedSome(runtime), + isLive: () => Effect.succeed(true), + close: () => Effect.void, + closeInstance: () => Effect.void, + release: () => Effect.void, + detach: () => Effect.void, + }), + ), + ), + ), + ); + + yield* Effect.gen(function* () { + const control = yield* ProviderTurnControlService.ProviderTurnControlServiceV2; + yield* control.steer({ + threadId, + providerSessionId, + providerThreadId, + providerTurnId, + messageId, + }); + }).pipe(Effect.provide(layer)); + + return yield* Ref.get(steered); + }); +} + +it.effect.each([ + { + label: "an agent thread send names the sender and is not the user", + createdBy: "agent" as const, + creationSource: "mcp" as const, + senderThreadId: steerChildThreadId, + senderTitle: "Review the PR", + expected: `Agent-authored message from "Review the PR", not the user.\n${steerBody}`, + }, + { + label: "an agent send from this thread's delegated task says so", + createdBy: "agent" as const, + creationSource: "mcp" as const, + senderThreadId: steerChildThreadId, + senderTitle: "Review the PR", + subagents: [{ origin: "app_owned" as const, childThreadId: steerChildThreadId }], + expected: `Agent-authored message from your delegated task "Review the PR", not the user.\n${steerBody}`, + }, + { + label: "a failed sender lookup still delivers an id-only header", + createdBy: "agent" as const, + creationSource: "mcp" as const, + senderThreadId: steerChildThreadId, + senderLookupFails: true, + subagents: [{ origin: "app_owned" as const, childThreadId: steerChildThreadId }], + expected: `Agent-authored message from your delegated task ${steerChildThreadId}, not the user.\n${steerBody}`, + }, + { + label: "a failed relationship lookup does not claim a delegated task", + createdBy: "agent" as const, + creationSource: "mcp" as const, + senderThreadId: steerChildThreadId, + senderTitle: "Review the PR", + subagentLookupFails: true, + expected: `Agent-authored message from "Review the PR", not the user.\n${steerBody}`, + }, + { + label: "a user steer is unchanged", + createdBy: "user" as const, + creationSource: "web" as const, + senderThreadId: steerChildThreadId, + senderTitle: "Review the PR", + expected: steerBody, + }, + { + label: "an agent steer with no sender thread is unchanged", + createdBy: "agent" as const, + creationSource: "server" as const, + expected: steerBody, + }, + { + label: "an agent Cursor /compress stays raw", + createdBy: "agent" as const, + creationSource: "mcp" as const, + senderThreadId: steerChildThreadId, + senderTitle: "Review the PR", + text: "/compress", + expected: "/compress", + }, + { + label: "a mid-message skill mention still names the sender", + createdBy: "agent" as const, + creationSource: "mcp" as const, + senderThreadId: steerChildThreadId, + senderTitle: "Review the PR", + text: "Ship $deploy now", + expected: `Agent-authored message from "Review the PR", not the user.\nShip $deploy now`, + }, +])("steers provider text so $label", (input) => + Effect.gen(function* () { + const text = yield* steerProviderText(input); + assert.equal(text, input.expected); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 3a5bc04ecba2e..410bb9d905e06 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -17,6 +17,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -345,6 +346,20 @@ export const layer: Layer.Layer< cause: "The persisted steering message or target run is missing.", }); } + const text = yield* providerTextForTurn({ + text: projectComposerContextForProvider({ + text: message.text, + records: message.context?.records ?? [], + }), + createdBy: message.createdBy, + senderThreadId: message.senderThreadId, + readSenderTitle: (senderThreadId) => + projections.getThread(senderThreadId).pipe(Effect.map((thread) => thread.title)), + readReceiverSubagents: () => + projections + .getThreadRecords(input.threadId, ["subagents"]) + .pipe(Effect.map((records) => records.subagents)), + }); yield* loaded.session.value .steerTurn({ threadId: input.threadId, @@ -353,10 +368,7 @@ export const layer: Layer.Layer< providerTurnId: loaded.providerTurn.id, message: { messageId: message.id, - text: projectComposerContextForProvider({ - text: message.text, - records: message.context?.records ?? [], - }), + text, attachments: message.attachments, createdBy: message.createdBy, creationSource: message.creationSource, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 7a922d79c7317..4936442f73403 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -286,6 +286,14 @@ function makeLocalCommandHarness(input: { readonly interruptFailure?: boolean; readonly interruptCurrentRun?: boolean; }; + readonly createdBy?: OrchestrationV2ThreadProjection["messages"][number]["createdBy"]; + readonly creationSource?: OrchestrationV2ThreadProjection["messages"][number]["creationSource"]; + readonly senderThreadId?: ThreadId; + readonly subagents?: OrchestrationV2ThreadProjection["subagents"]; + readonly senderTitles?: Readonly>; + readonly senderLookupFails?: boolean; + readonly handlePromptCommand?: boolean; + readonly attachments?: OrchestrationV2ThreadProjection["messages"][number]["attachments"]; }) { const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); const threadId = ThreadId.make("thread-native-account-command"); @@ -340,10 +348,11 @@ function makeLocalCommandHarness(input: { nodeId: rootNodeId, role: "user", text: input.text, - attachments: [], + attachments: input.attachments ?? [], streaming: false, - createdBy: "user", - creationSource: "web", + createdBy: input.createdBy ?? "user", + creationSource: input.creationSource ?? "web", + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), createdAt: now, updatedAt: now, }; @@ -453,7 +462,7 @@ function makeLocalCommandHarness(input: { turnItems: [], visibleTurnItems: [], runtimeRequests: [], - subagents: [], + subagents: input.subagents ?? [], plans: [], checkpoints: [], updatedAt: now, @@ -636,15 +645,17 @@ function makeLocalCommandHarness(input: { : Effect.void, ); const tryHandlePromptCommand = vi.fn(() => - input.logoutFailure === undefined - ? Effect.succeed(true) - : Effect.fail( - new ProviderSetupError({ - instanceId: oldInstanceId, - operation: "logout", - detail: input.logoutFailure, - }), - ), + input.handlePromptCommand === false + ? Effect.succeed(false) + : input.logoutFailure === undefined + ? Effect.succeed(true) + : Effect.fail( + new ProviderSetupError({ + instanceId: oldInstanceId, + operation: "logout", + detail: input.logoutFailure, + }), + ), ); const writeIfRunCurrent = vi.fn(({ events: incoming, activeAttemptId, expectedStatus }) => "writeFailure" in input @@ -717,6 +728,19 @@ function makeLocalCommandHarness(input: { }), Layer.mock(ProjectionStore.ProjectionStoreV2)({ getThreadRecords, + getThread: (threadId: ThreadId) => + input.createdBy === "user" + ? Effect.die("user messages do not look up a sender") + : input.senderLookupFails === true + ? Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId, + cause: "sender lookup failed", + }), + ) + : Effect.succeed({ + title: input.senderTitles?.[threadId] ?? "Sender", + } as never), getTurnStartContext, getRuntimeRecoveryProjection: () => Effect.as(failReadIfRunning, { @@ -1417,3 +1441,117 @@ for (const previousMessages of [[], ["/compact", " /COMPACT "]]) { }), ); } + +const agentSendBody = "Please merge the PR now."; +const settledPreviousTurn = { + runStatus: "completed", + turnStatus: "completed", +} as const; +const childThreadId = ThreadId.make("thread:child-review"); + +function childSubagent( + origin: "app_owned" | "provider_native", +): OrchestrationV2ThreadProjection["subagents"][number] { + return { + origin, + childThreadId, + status: "running", + } as OrchestrationV2ThreadProjection["subagents"][number]; +} + +effectIt.effect.each([ + { + label: "an agent thread send names the sender and is not the user", + createdBy: "agent" as const, + creationSource: "mcp" as const, + senderThreadId: childThreadId, + senderTitles: { [childThreadId]: "Review the PR" }, + expected: `Agent-authored message from "Review the PR", not the user.\n${agentSendBody}`, + }, + { + label: "an agent send from this thread's delegated task says so", + createdBy: "agent" as const, + creationSource: "mcp" as const, + senderThreadId: childThreadId, + senderTitles: { [childThreadId]: "Review the PR" }, + subagents: [childSubagent("app_owned")], + expected: `Agent-authored message from your delegated task "Review the PR", not the user.\n${agentSendBody}`, + }, + { + label: "a provider-native child is not called a delegated task", + createdBy: "agent" as const, + creationSource: "mcp" as const, + senderThreadId: childThreadId, + senderTitles: { [childThreadId]: "Review the PR" }, + subagents: [childSubagent("provider_native")], + expected: `Agent-authored message from "Review the PR", not the user.\n${agentSendBody}`, + }, + { + label: "a missing sender thread still delivers an id-only header", + createdBy: "agent" as const, + creationSource: "mcp" as const, + senderThreadId: childThreadId, + senderLookupFails: true, + expected: `Agent-authored message from ${childThreadId}, not the user.\n${agentSendBody}`, + }, + { + label: "a user message is unchanged", + createdBy: "user" as const, + creationSource: "web" as const, + senderThreadId: childThreadId, + senderTitles: { [childThreadId]: "Review the PR" }, + expected: agentSendBody, + }, + { + label: "an agent message with no sender thread is unchanged", + createdBy: "agent" as const, + creationSource: "server" as const, + expected: agentSendBody, + }, +])("assembles provider text so $label", (input) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: agentSendBody, + previousTurn: settledPreviousTurn, + createdBy: input.createdBy, + creationSource: input.creationSource, + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + ...(input.subagents === undefined ? {} : { subagents: input.subagents }), + ...(input.senderTitles === undefined ? {} : { senderTitles: input.senderTitles }), + ...(input.senderLookupFails === undefined + ? {} + : { senderLookupFails: input.senderLookupFails }), + }); + + yield* harness.start; + + expect(harness.projection().messages.at(-1)?.text).toBe(agentSendBody); + expect(harness.startRootRun.mock.calls[0]?.[0].message.text).toBe(input.expected); + }), +); + +effectIt.effect.each([ + { text: "/compact", label: "an agent /compact still compacts" }, + { text: "/goal x", label: "an agent /goal x still routes" }, + { text: "/compress", label: "an agent Cursor /compress stays raw" }, + { text: "/legacy files", label: "an agent Claude legacy command stays raw" }, +])("assembles provider text so $label", ({ text }) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text, + previousMessages: ["Earlier turn"], + previousTurn: settledPreviousTurn, + createdBy: "agent", + creationSource: "mcp", + senderThreadId: childThreadId, + senderTitles: { [childThreadId]: "Review the PR" }, + handlePromptCommand: false, + }); + + yield* harness.start; + + expect(harness.projection().messages.at(-1)?.text).toBe(text); + expect(harness.startRootRun.mock.calls[0]?.[0].message.text).toBe(text); + expect(harness.tryHandlePromptCommand).toHaveBeenCalled(); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 9d9ae2e646cd5..e01604c5e1bf6 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -31,6 +31,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as ServerSettings from "../serverSettings.ts"; import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; import * as EventSink from "./EventSink.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import { @@ -1092,9 +1093,16 @@ export const layer: Layer.Layer< const routableSubagents = projection.subagents.filter((subagent) => RunExecutionService.canRouteRelatedSubagent(subagent.status), ); - const userText = projectComposerContextForProvider({ - text: message.text, - records: message.context?.records ?? [], + const userText = yield* providerTextForTurn({ + text: projectComposerContextForProvider({ + text: message.text, + records: message.context?.records ?? [], + }), + createdBy: message.createdBy, + senderThreadId: message.senderThreadId, + readSenderTitle: (senderThreadId) => + projectionStore.getThread(senderThreadId).pipe(Effect.map((thread) => thread.title)), + readReceiverSubagents: () => Effect.succeed(projection.subagents), }); // Codex and Claude parse /goal from the prompt, so it goes verbatim and history waits a turn. const goalDriver = session.driver === "codex" || session.driver === "claudeAgent"; diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 394cc1527cb7d..e482d8ae24234 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -47,6 +47,7 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts"; import * as PreviewAgentActivity from "../preview/PreviewAgentActivity.ts"; +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; import * as RunExecutionService from "./RunExecutionService.ts"; import * as RunFinalizationService from "./RunFinalizationService.ts"; import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; @@ -1016,87 +1017,93 @@ it.effect("fails the run when its ownership check cannot be read before calling }), ); -it.effect( - "dispatches only attachment-free compact commands through the native compaction path", - () => - Effect.gen(function* () { - const runExecution = yield* RunExecutionService.RunExecutionServiceV2; - const calls: Array = []; - const attachment = ChatFileAttachment.make({ - type: "file", - id: ChatAttachmentId.make("compact-file-12345678-1234-1234-1234-123456789abc"), - name: "notes.txt", - mimeType: "text/plain", - sizeBytes: 1, - }); - const cases = [ - { text: " /compact ", attachments: [], expected: "compact" }, - { text: "/compact", attachments: [attachment], expected: "prompt" }, - { text: "Continue the work", attachments: [], expected: "prompt" }, - ]; - for (const [index, testCase] of cases.entries()) { - const threadId = ThreadId.make(`thread:compact-routing:${index}`); - const attemptId = RunAttemptId.make(`attempt:compact-routing:${index}`); - yield* runExecution.startRootRun({ - commandId: CommandId.make(`command:compact-routing:${index}`), - appThread: rootAppThread(threadId), - providerSessionId: ProviderSessionId.make(`session:compact-routing:${index}`), - session: { - events: Stream.never, - startTurn: () => - Effect.sync(() => { - calls.push("prompt"); - }), - compactThread: () => - Effect.sync(() => { - calls.push("compact"); - }), - } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, - run: { - id: RunId.make(`run:compact-routing:${index}`), - threadId, - ordinal: 1, - providerInstanceId: ProviderInstanceId.make("codex"), - } as OrchestrationV2Run, - rootNode: { - id: NodeId.make(`node:compact-routing:${index}`), - } as OrchestrationV2ExecutionNode, - checkpointScope: { - id: CheckpointScopeId.make(`checkpoint:compact-routing:${index}`), - } as OrchestrationV2CheckpointScope, - providerThread: { - id: ProviderThreadId.make(`provider-thread:compact-routing:${index}`), - driver, - } as OrchestrationV2ProviderThread, - attempt: { id: attemptId, providerTurnId: null } as OrchestrationV2RunAttempt, - attemptId, - providerTurnOrdinal: 1, - message: { - messageId: MessageId.make(`message:compact-routing:${index}`), - text: testCase.text, - attachments: testCase.attachments, - createdBy: "user", - creationSource: "web", - }, - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, +it.effect("an agent /compact still compacts", () => + Effect.gen(function* () { + const runExecution = yield* RunExecutionService.RunExecutionServiceV2; + const calls: Array = []; + const attachment = ChatFileAttachment.make({ + type: "file", + id: ChatAttachmentId.make("compact-file-12345678-1234-1234-1234-123456789abc"), + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 1, + }); + const agentCompact = yield* providerTextForTurn({ + text: "/compact", + createdBy: "agent", + senderThreadId: ThreadId.make("thread:agent-compact-sender"), + readSenderTitle: () => Effect.die("commands do not look up a sender"), + readReceiverSubagents: () => Effect.die("commands do not look up a sender"), + }); + const cases = [ + { text: " /compact ", attachments: [], expected: "compact" }, + { text: "/compact", attachments: [attachment], expected: "prompt" }, + { text: "Continue the work", attachments: [], expected: "prompt" }, + { text: agentCompact, attachments: [], expected: "compact" }, + ]; + for (const [index, testCase] of cases.entries()) { + const threadId = ThreadId.make(`thread:compact-routing:${index}`); + const attemptId = RunAttemptId.make(`attempt:compact-routing:${index}`); + yield* runExecution.startRootRun({ + commandId: CommandId.make(`command:compact-routing:${index}`), + appThread: rootAppThread(threadId), + providerSessionId: ProviderSessionId.make(`session:compact-routing:${index}`), + session: { + events: Stream.never, + startTurn: () => + Effect.sync(() => { + calls.push("prompt"); + }), + compactThread: () => + Effect.sync(() => { + calls.push("compact"); + }), + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, + run: { + id: RunId.make(`run:compact-routing:${index}`), + threadId, + ordinal: 1, + providerInstanceId: ProviderInstanceId.make("codex"), + } as OrchestrationV2Run, + rootNode: { + id: NodeId.make(`node:compact-routing:${index}`), + } as OrchestrationV2ExecutionNode, + checkpointScope: { + id: CheckpointScopeId.make(`checkpoint:compact-routing:${index}`), + } as OrchestrationV2CheckpointScope, + providerThread: { + id: ProviderThreadId.make(`provider-thread:compact-routing:${index}`), + driver, + } as OrchestrationV2ProviderThread, + attempt: { id: attemptId, providerTurnId: null } as OrchestrationV2RunAttempt, + attemptId, + providerTurnOrdinal: 1, + message: { + messageId: MessageId.make(`message:compact-routing:${index}`), + text: testCase.text, + attachments: testCase.attachments, + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, }, - }); - } - assert.deepEqual( - calls, - cases.map((testCase) => testCase.expected), - ); - }).pipe(Effect.provide(layerRunExecutionTest)), + }, + }); + } + assert.deepEqual( + calls, + cases.map((testCase) => testCase.expected), + ); + }).pipe(Effect.provide(layerRunExecutionTest)), ); it.effect("starts the provider when checkpoint baseline capture fails", () => diff --git a/apps/server/src/orchestration-v2/agentSendProvenance.test.ts b/apps/server/src/orchestration-v2/agentSendProvenance.test.ts new file mode 100644 index 0000000000000..0d7da46d3b1bc --- /dev/null +++ b/apps/server/src/orchestration-v2/agentSendProvenance.test.ts @@ -0,0 +1,189 @@ +import { assert, it } from "@effect/vitest"; +import { parseGoalCommand, ThreadId } from "@t3tools/contracts"; +import { leadingGrokSkillMention } from "@t3tools/provider-grok/server/skills"; +import { parsePiCompactCommand } from "@t3tools/provider-pi/server/commands"; +import * as Effect from "effect/Effect"; + +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; +import { planClaudeSkillDispatch } from "../provider/Drivers/ClaudeSkillDispatch.ts"; + +const senderThreadId = ThreadId.make("thread:child-review"); +const body = "Please merge the PR now.\n"; +const header = (text: string, name = `"Review 'the' PR"`) => + `Agent-authored message from ${name}, not the user.\n${text}`; +const claudeSkills = new Map([["deploy", { path: "/skills/deploy/SKILL.md" }]]); + +const lookups = { + readSenderTitle: () => Effect.succeed('Review\n"the" PR'), + readReceiverSubagents: () => Effect.succeed([]), +} as const; + +const untouched = { + readSenderTitle: () => Effect.die("native invocations do not look up a sender"), + readReceiverSubagents: () => Effect.die("native invocations do not look up a sender"), +} as const; + +it.effect("an ordinary agent message still gets the header", () => + Effect.gen(function* () { + const text = yield* providerTextForTurn({ + text: body, + createdBy: "agent", + senderThreadId, + ...lookups, + }); + + assert.equal(text, header(body)); + }), +); + +it.effect("uses the thread id when the title is blank", () => + Effect.gen(function* () { + const text = yield* providerTextForTurn({ + text: body, + createdBy: "agent", + senderThreadId, + readSenderTitle: () => Effect.succeed(" \n\t "), + readReceiverSubagents: () => Effect.succeed([]), + }); + + assert.equal(text, header(body, senderThreadId)); + }), +); + +it.effect("leaves a user message untouched and does no extra work", () => + Effect.gen(function* () { + const text = yield* providerTextForTurn({ + text: "/compress", + createdBy: "user", + senderThreadId, + ...untouched, + }); + + assert.equal(text, "/compress"); + }), +); + +it.effect("leaves a system message unchanged", () => + Effect.gen(function* () { + const text = yield* providerTextForTurn({ + text: body, + createdBy: "system", + senderThreadId, + ...untouched, + }); + + assert.equal(text, body); + }), +); + +it.effect("an agent /compact still compacts", () => + Effect.gen(function* () { + const text = yield* providerTextForTurn({ + text: " /compact ", + createdBy: "agent", + senderThreadId, + ...untouched, + }); + + assert.equal(text, " /compact "); + assert.equal(text.trim().toLowerCase(), "/compact"); + assert.notEqual(parsePiCompactCommand(text), null); + }), +); + +it.effect("an agent /goal x still routes", () => + Effect.gen(function* () { + const text = yield* providerTextForTurn({ + text: "/goal x", + createdBy: "agent", + senderThreadId, + ...untouched, + }); + + assert.deepEqual(parseGoalCommand({ text }), { type: "set", objective: "x" }); + }), +); + +it.effect("an agent $deploy ship it still matches the Grok skill parser", () => + Effect.gen(function* () { + const text = yield* providerTextForTurn({ + text: "$deploy ship it", + createdBy: "agent", + senderThreadId, + ...untouched, + }); + + assert.deepEqual(leadingGrokSkillMention(text), { name: "deploy", rest: " ship it" }); + }), +); + +it.effect("keeps a leading slash raw, including Cursor /compress and a Claude legacy command", () => + Effect.gen(function* () { + for (const text of [ + "/compress", + "/legacy files", + "/review staged changes", + "/please review this", + "/my-command", + "/skill:deploy", + ]) { + const providerText = yield* providerTextForTurn({ + text, + createdBy: "agent", + senderThreadId, + ...untouched, + }); + assert.equal(providerText, text); + } + }), +); + +it.effect("keeps a leading skill token raw and still headers a price or a later mention", () => + Effect.gen(function* () { + const home = yield* providerTextForTurn({ + text: "$HOME is unset; use the fallback.", + createdBy: "agent", + senderThreadId, + ...untouched, + }); + assert.equal(home, "$HOME is unset; use the fallback."); + + const price = yield* providerTextForTurn({ + text: "$25 off", + createdBy: "agent", + senderThreadId, + ...lookups, + }); + assert.equal(price, header("$25 off")); + + const later = yield* providerTextForTurn({ + text: "Ship $deploy now", + createdBy: "agent", + senderThreadId, + readSenderTitle: () => Effect.succeed("Review $deploy job"), + readReceiverSubagents: () => Effect.succeed([]), + }); + assert.equal( + later, + `Agent-authored message from "Review $\u2060deploy job", not the user.\nShip $deploy now`, + ); + const plan = planClaudeSkillDispatch(later, claudeSkills); + assert.equal(plan?.skillName, "deploy"); + assert.equal(plan?.commandText, "/deploy now"); + }), +); + +it.effect("a skill token in the sender title does not invoke that skill", () => + Effect.gen(function* () { + for (const title of ["Review $deploy job", "Review €deploy job", "Review £deploy job"]) { + const text = yield* providerTextForTurn({ + text: "The review is complete.", + createdBy: "agent", + senderThreadId, + readSenderTitle: () => Effect.succeed(title), + readReceiverSubagents: () => Effect.succeed([]), + }); + assert.isUndefined(planClaudeSkillDispatch(text, claudeSkills)); + } + }), +); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson index 52c196b151abd..b5c8e85e866e3 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson @@ -11,7 +11,7 @@ {"type":"emit_inbound","label":"parent.turn/started","frame":{"method":"turn/started","params":{"threadId":"019f1000-0000-7000-8000-000000000001","turn":{"id":"019f1000-0000-7000-8000-000000000002","items":[],"status":"inProgress","error":null,"startedAt":1776739467,"completedAt":null,"durationMs":null}}}} {"type":"expect_outbound","label":"child.thread/start","frame":{"id":4,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000}}}} {"type":"emit_inbound","label":"child.thread/start","frame":{"id":4,"result":{"thread":{"id":"019f1000-0000-7000-8000-000000000003","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739468,"updatedAt":1776739468,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/delegated-task-status-child.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]},"model":"gpt-5.4","modelProvider":"openai","serviceTier":"fast","cwd":"/home/replay-user/t3code","approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"reasoningEffort":"xhigh"}}} -{"type":"expect_outbound","label":"child.original.turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Inspect the delegated API boundary and return the result.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} +{"type":"expect_outbound","label":"child.original.turn/start","frame":{"id":5,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Agent-authored message from \"MCP replay parent\", not the user.\nInspect the delegated API boundary and return the result.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} {"type":"emit_inbound","label":"child.thread/started","frame":{"method":"thread/started","params":{"thread":{"id":"019f1000-0000-7000-8000-000000000003","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739468,"updatedAt":1776739468,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/delegated-task-status-child.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]}}}} {"type":"emit_inbound","label":"child.original.turn/start","frame":{"id":5,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000004","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"child.original.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"active","activeFlags":[]}}}} @@ -21,7 +21,7 @@ {"type":"emit_inbound","label":"child.original.item/completed","frame":{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_delegated_task_status_original","text":"Delegated API boundary inspected.","phase":"final_answer","memoryCitation":null},"threadId":"019f1000-0000-7000-8000-000000000003","turnId":"019f1000-0000-7000-8000-000000000004"}}} {"type":"emit_inbound","label":"child.original.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"child.original.turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turn":{"id":"019f1000-0000-7000-8000-000000000004","items":[],"status":"completed","error":null,"startedAt":1776739468,"completedAt":1776739469,"durationMs":1000}}}} -{"type":"expect_outbound","label":"child.running.turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Remain active until the parent cancels this delegated task.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} +{"type":"expect_outbound","label":"child.running.turn/start","frame":{"id":6,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Agent-authored message from \"MCP replay parent\", not the user.\nRemain active until the parent cancels this delegated task.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} {"type":"emit_inbound","label":"child.running.turn/start","frame":{"id":6,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000005","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"child.running.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"child.running.turn/started","frame":{"method":"turn/started","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turn":{"id":"019f1000-0000-7000-8000-000000000005","items":[],"status":"inProgress","error":null,"startedAt":1776739470,"completedAt":null,"durationMs":null}}}} @@ -29,7 +29,7 @@ {"type":"emit_inbound","label":"child.running.turn/interrupt","frame":{"id":7,"result":{}}} {"type":"emit_inbound","label":"child.running.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"idle"}}}} {"type":"emit_inbound","label":"child.running.turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turn":{"id":"019f1000-0000-7000-8000-000000000005","items":[],"status":"interrupted","error":null,"startedAt":1776739470,"completedAt":1776739471,"durationMs":1000}}}} -{"type":"expect_outbound","label":"child.queued.turn/start","frame":{"id":8,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Complete the queued follow-up and return the final result.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} +{"type":"expect_outbound","label":"child.queued.turn/start","frame":{"id":8,"method":"turn/start","params":{"approvalPolicy":"never","cwd":"","input":[{"text":"Agent-authored message from \"MCP replay parent\", not the user.\nComplete the queued follow-up and return the final result.","type":"text"}],"model":"gpt-5.4","sandboxPolicy":{"type":"dangerFullAccess"},"threadId":"019f1000-0000-7000-8000-000000000003","approvalsReviewer":"user","summary":"detailed","collaborationMode":{"mode":"default","settings":{"model":"gpt-5.4"}}}}} {"type":"emit_inbound","label":"child.queued.turn/start","frame":{"id":8,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000006","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"child.queued.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000003","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"child.queued.turn/started","frame":{"method":"turn/started","params":{"threadId":"019f1000-0000-7000-8000-000000000003","turn":{"id":"019f1000-0000-7000-8000-000000000006","items":[],"status":"inProgress","error":null,"startedAt":1776739472,"completedAt":null,"durationMs":null}}}} diff --git a/packages/provider-grok/package.json b/packages/provider-grok/package.json index 95ba20214e81a..fb084e88fc916 100644 --- a/packages/provider-grok/package.json +++ b/packages/provider-grok/package.json @@ -15,6 +15,10 @@ "types": "./src/server.ts", "import": "./src/server.ts" }, + "./server/skills": { + "types": "./src/server/skills.ts", + "import": "./src/server/skills.ts" + }, "./server/usage": { "types": "./src/server/usage.ts", "import": "./src/server/usage.ts" diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index cec5c7847c885..8792e4190f308 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -36,6 +36,7 @@ import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; import { advanceOpenCodePromptAdmission, cancelOpenCodePromptAdmission, @@ -1124,6 +1125,115 @@ describe("OpenCodeAdapterV2", () => { ), ); + it.effect("keeps an agent /review on the native command after the sender line", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const release = promiseGate(); + const calls = yield* Queue.unbounded<{ + messageID: string; + command: string; + arguments: string; + }>(); + let prompted = ""; + const sessionId = "native-command-provenance"; + const client = { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + command: { list: async () => ({ data: [{ name: "review" }] }) }, + session: { + create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + command: async (input: { command: string; arguments: string; messageID: string }) => { + Queue.offerUnsafe(calls, input); + await release.promise; + return { data: true }; + }, + promptAsync: async (input: { + parts?: ReadonlyArray<{ type: string; text?: string }>; + }) => { + prompted = (input.parts ?? []) + .flatMap((part) => + part.type === "text" && part.text !== undefined ? [part.text] : [], + ) + .join("\n"); + return { data: true }; + }, + }, + }; + const commandHarness = yield* makeOpenCodeRuntimeHarness( + "native-command-provenance", + sessionId, + client, + ); + const commandText = yield* providerTextForTurn({ + text: "/review staged changes", + createdBy: "agent", + senderThreadId: ThreadId.make("thread:child-review"), + readSenderTitle: () => Effect.die("commands do not look up a sender"), + readReceiverSubagents: () => Effect.die("commands do not look up a sender"), + }); + const start = yield* commandHarness.startTurn(commandText).pipe(Effect.forkScoped); + const call = yield* Queue.take(calls); + assert.equal(commandText, "/review staged changes"); + assert.equal(call.command, "review"); + assert.equal(call.arguments, "staged changes"); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: sessionId, + info: { + id: call.messageID, + sessionID: sessionId, + role: "user", + time: { created: DateTime.toEpochMillis(commandHarness.now) }, + }, + }, + }), + ); + release.resolve(); + yield* Fiber.join(start); + assert.equal(prompted, ""); + + const proseEvents = asyncEventStream(); + const proseHarness = yield* makeOpenCodeRuntimeHarness( + "native-command-prose", + "native-command-prose", + { + ...client, + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => proseEvents.close(), { once: true }); + return { stream: proseEvents.stream }; + }, + }, + session: { + ...client.session, + create: async () => ({ + data: { id: "native-command-prose", time: { created: 1, updated: 1 } }, + }), + }, + }, + ); + const prose = yield* providerTextForTurn({ + text: "/please review this", + createdBy: "agent", + senderThreadId: ThreadId.make("thread:child-review"), + readSenderTitle: () => Effect.die("a leading slash does not look up a sender"), + readReceiverSubagents: () => Effect.die("a leading slash does not look up a sender"), + }); + yield* proseHarness.startTurn(prose); + assert.equal(prose, "/please review this"); + assert.equal(prompted, "/please review this"); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect("compacts with the native summarize API and emits a completed compaction", () => Effect.gen(function* () { const nativeEvents = asyncEventStream(); diff --git a/packages/provider-pi/package.json b/packages/provider-pi/package.json index 3eb548606a456..06fe3a9396329 100644 --- a/packages/provider-pi/package.json +++ b/packages/provider-pi/package.json @@ -15,6 +15,10 @@ "types": "./src/server.ts", "import": "./src/server.ts" }, + "./server/commands": { + "types": "./src/server/commands.ts", + "import": "./src/server/commands.ts" + }, "./server/usage": { "types": "./src/server/usage.ts", "import": "./src/server/usage.ts" diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 544ac5cce66a3..1bd61defcb860 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -1,3 +1,4 @@ +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { @@ -520,6 +521,29 @@ const startTurn = Effect.fnUntraced(function* ( }); describe("PiAdapterV2", () => { + it.effect("keeps an agent custom command at the start of the prompt", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const text = yield* providerTextForTurn({ + text: "/my-command", + createdBy: "agent", + senderThreadId: ThreadId.make("thread:child-review"), + readSenderTitle: () => Effect.die("commands do not look up a sender"), + readReceiverSubagents: () => Effect.die("commands do not look up a sender"), + }); + yield* startTurn(runtime, providerThread, "default", [], text); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(text, "/my-command"); + assert.equal(prompt["message"], "/my-command"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("keeps native image bytes and the saved path in the prompt", () => Effect.gen(function* () { const host = yield* ProviderHost.ProviderHost; diff --git a/packages/provider-pi/src/server/mcpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts index b1306f701e27a..21a8796177bed 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.test.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.test.ts @@ -5,7 +5,10 @@ import * as NodeOS from "node:os"; import * as NodeModule from "node:module"; import * as NodeVM from "node:vm"; import { assert, describe, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts"; import { loadMcpBridge } from "./mcpBridge.testkit.ts"; @@ -519,4 +522,35 @@ describe("Pi skill references", () => { await NodeFSP.rm(directory, { recursive: true }); } }); + + it.effect.each(["deploy", "25", "123", "deploy.v2"])( + "does not load skill %s from the sender title", + (name) => + Effect.gen(function* () { + const directory = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-title-skill-")), + ); + yield* Effect.gen(function* () { + const path = NodePath.join(directory, "SKILL.md"); + yield* Effect.promise(() => NodeFSP.writeFile(path, "TITLE_SKILL_INSTRUCTIONS")); + const hook = yield* Effect.promise(() => + loadInputHook([{ name: `skill:${name}`, source: "skill", sourceInfo: { path } }]), + ); + const body = "The review is complete."; + const quiet = { ui: { notify: assert.fail } }; + assert.isUndefined(yield* Effect.promise(() => hook({ text: body }, quiet))); + const text = yield* providerTextForTurn({ + text: body, + createdBy: "agent", + senderThreadId: ThreadId.make("thread:title-skill"), + readSenderTitle: () => Effect.succeed(`Review $${name} job`), + readReceiverSubagents: () => Effect.succeed([]), + }); + assert.isUndefined(yield* Effect.promise(() => hook({ text }, quiet))); + const requested = yield* Effect.promise(() => hook({ text: `Use $${name} now` }, quiet)); + assert.include(requested?.text ?? "", `Use $${name} now`); + assert.include(requested?.text ?? "", "TITLE_SKILL_INSTRUCTIONS"); + }).pipe(Effect.ensuring(Effect.promise(() => NodeFSP.rm(directory, { recursive: true })))); + }), + ); }); diff --git a/packages/shared/package.json b/packages/shared/package.json index f968d276883e1..326642069e58a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./agentSendProvenance": { + "types": "./src/agentSendProvenance.ts", + "import": "./src/agentSendProvenance.ts" + }, "./changedFileKind": { "types": "./src/changedFileKind.ts", "import": "./src/changedFileKind.ts" diff --git a/packages/shared/src/agentSendProvenance.ts b/packages/shared/src/agentSendProvenance.ts new file mode 100644 index 0000000000000..eb855c493e278 --- /dev/null +++ b/packages/shared/src/agentSendProvenance.ts @@ -0,0 +1,63 @@ +import type { OrchestrationV2Actor, OrchestrationV2Subagent, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { SKILL_MENTION_PATTERN } from "./composerInlineTokens.ts"; + +const SKILL_AT_START = new RegExp(SKILL_MENTION_PATTERN.source, "u"); + +/** + * Agent thread sends are stored with a sender, but the provider only receives the body. + * One header line names that sender and says the text is not the user's. A failed + * title or relationship lookup still returns a header and never fails the turn. + */ +export const providerTextForTurn = Effect.fn("providerTextForTurn")(function* < + TitleError, + SubagentError, +>(input: { + readonly text: string; + readonly createdBy: OrchestrationV2Actor; + readonly senderThreadId?: ThreadId | undefined; + readonly readSenderTitle: (senderThreadId: ThreadId) => Effect.Effect; + readonly readReceiverSubagents: () => Effect.Effect< + ReadonlyArray>, + SubagentError + >; +}) { + if (input.createdBy !== "agent" || input.senderThreadId === undefined) { + return input.text; + } + if (preservesNativeSyntax(input.text)) { + return input.text; + } + const senderThreadId = input.senderThreadId; + const title = Option.getOrUndefined(yield* Effect.option(input.readSenderTitle(senderThreadId))); + const subagents = Option.getOrUndefined(yield* Effect.option(input.readReceiverSubagents())); + const delegatedChild = + subagents?.some( + (subagent) => subagent.origin === "app_owned" && subagent.childThreadId === senderThreadId, + ) ?? false; + const name = senderName(title, senderThreadId); + const source = delegatedChild ? `your delegated task ${name}` : name; + return `Agent-authored message from ${source}, not the user.\n${input.text}`; +}); + +function preservesNativeSyntax(text: string): boolean { + const trimmed = text.trimStart(); + if (trimmed.startsWith("/")) return true; + const mention = SKILL_AT_START.exec(trimmed); + return mention !== null && mention.index === 0 && mention[1] === ""; +} + +// Pi treats any $token as a skill, including digits. A word joiner after every currency sigil keeps the title from matching. +function neutralizeSkillIntroducers(text: string): string { + return text.replace(/\p{Sc}/gu, "$&\u2060"); +} + +function senderName(title: string | undefined, senderThreadId: ThreadId): string { + if (title === undefined) return senderThreadId; + const singleLine = neutralizeSkillIntroducers( + title.replaceAll(/\s+/g, " ").replaceAll('"', "'"), + ).trim(); + return singleLine.length === 0 ? senderThreadId : `"${singleLine}"`; +}