From 15d77570208890b512c3a7e18cfe727b4ccf61c1 Mon Sep 17 00:00:00 2001 From: Wes Hines Date: Sat, 24 Oct 2026 23:03:00 -0500 Subject: [PATCH 1/7] fix(server): agent thread sends tell the receiving model who sent them Agent-authored thread sends reached the provider as raw user text. Prefix one provenance line where new turns and steers assemble provider text, and leave the stored message unchanged. Model: Grok 4.7 Co-authored-by: Cursor --- .../ProviderTurnControlService.test.ts | 240 ++++++++++++++++++ .../ProviderTurnControlService.ts | 20 +- .../ProviderTurnStartService.test.ts | 112 +++++++- .../ProviderTurnStartService.ts | 14 +- .../agentSendProvenance.test.ts | 50 ++++ .../orchestration-v2/agentSendProvenance.ts | 43 ++++ 6 files changed, 469 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/orchestration-v2/agentSendProvenance.test.ts create mode 100644 apps/server/src/orchestration-v2/agentSendProvenance.ts diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 42646e974ec03..841a038c4ba32 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, @@ -331,3 +333,241 @@ 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 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: steerBody, + 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.senderLookupFails === true + ? Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId: requestedThreadId, + cause: "sender lookup failed", + }), + ) + : Effect.succeed({ + title: input.senderTitle ?? "Sender", + } as never), + getThreadRecords: () => + 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, + }, +])("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..f64491de9ed4d 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 "./agentSendProvenance.ts"; 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 18d217403b393..bc863ad87c81a 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -278,6 +278,12 @@ 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; }) { const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); const threadId = ThreadId.make("thread-native-account-command"); @@ -334,8 +340,9 @@ function makeLocalCommandHarness(input: { text: input.text, 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, }; @@ -445,7 +452,7 @@ function makeLocalCommandHarness(input: { turnItems: [], visibleTurnItems: [], runtimeRequests: [], - subagents: [], + subagents: input.subagents ?? [], plans: [], checkpoints: [], updatedAt: now, @@ -702,6 +709,17 @@ function makeLocalCommandHarness(input: { }), Layer.mock(ProjectionStore.ProjectionStoreV2)({ getThreadRecords, + getThread: (threadId: ThreadId) => + 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, { @@ -1241,3 +1259,91 @@ 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); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 6b851bb26a230..5bd3866a9d41f 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 "./agentSendProvenance.ts"; import * as EventSink from "./EventSink.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import { @@ -1025,9 +1026,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), }); // The provider turn marks cancellation work delivered; restart continuations use their own text or resume natively. const noteContinuation = isRestartNoteContinuation( 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..f78a3ef564148 --- /dev/null +++ b/apps/server/src/orchestration-v2/agentSendProvenance.test.ts @@ -0,0 +1,50 @@ +import { assert, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { providerTextForTurn } from "./agentSendProvenance.ts"; + +const senderThreadId = ThreadId.make("thread:child-review"); +const body = "Please merge the PR now.\n"; + +it.effect("keeps the original text after one header line", () => + Effect.gen(function* () { + const text = yield* providerTextForTurn({ + text: body, + createdBy: "agent", + senderThreadId, + readSenderTitle: () => Effect.succeed('Review\n"the" PR'), + readReceiverSubagents: () => Effect.succeed([]), + }); + + assert.equal(text, `Agent-authored message from "Review 'the' PR", not the user.\n${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, `Agent-authored message from ${senderThreadId}, not the user.\n${body}`); + }), +); + +it.effect("leaves a system message unchanged", () => + Effect.gen(function* () { + const text = yield* providerTextForTurn({ + text: body, + createdBy: "system", + senderThreadId, + readSenderTitle: () => Effect.die("system messages do not look up a sender"), + readReceiverSubagents: () => Effect.die("system messages do not look up a sender"), + }); + + assert.equal(text, body); + }), +); diff --git a/apps/server/src/orchestration-v2/agentSendProvenance.ts b/apps/server/src/orchestration-v2/agentSendProvenance.ts new file mode 100644 index 0000000000000..64dba4ebcc933 --- /dev/null +++ b/apps/server/src/orchestration-v2/agentSendProvenance.ts @@ -0,0 +1,43 @@ +import type { OrchestrationV2Actor, OrchestrationV2Subagent, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +/** + * 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 function providerTextForTurn(input: { + readonly text: string; + readonly createdBy: OrchestrationV2Actor; + readonly senderThreadId?: ThreadId | undefined; + readonly readSenderTitle: (senderThreadId: ThreadId) => Effect.Effect; + readonly readReceiverSubagents: () => Effect.Effect< + ReadonlyArray>, + SubagentError + >; +}): Effect.Effect { + if (input.createdBy !== "agent" || input.senderThreadId === undefined) { + return Effect.succeed(input.text); + } + const senderThreadId = input.senderThreadId; + return Effect.gen(function* () { + 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 senderName(title: string | undefined, senderThreadId: ThreadId): string { + if (title === undefined) return senderThreadId; + const singleLine = title.replaceAll(/\s+/g, " ").replaceAll('"', "'").trim(); + return singleLine.length === 0 ? senderThreadId : `"${singleLine}"`; +} From 5fbf0109b49c7c0ab1a205c874ae0614fab303eb Mon Sep 17 00:00:00 2001 From: Wes Hines Date: Sat, 24 Oct 2026 23:25:22 -0500 Subject: [PATCH 2/7] fix(server): keep native commands and skill tokens out of the provenance header The header was the provider text, so /compact, Codex /goal, and a leading Grok skill no longer matched the handlers that already recognise them. Skip the header for those invocations, and expect it on the delegated-task replay. Model: Grok 4.7 Co-authored-by: Cursor --- ...OrchestratorMcpToolkit.integration.test.ts | 14 ++ .../Adapters/CodexAdapterV2.ts | 37 +--- .../Adapters/CodexGoalCommand.ts | 35 ++++ .../ProviderTurnControlService.ts | 1 + .../ProviderTurnStartService.test.ts | 45 ++++- .../ProviderTurnStartService.ts | 2 + .../RunExecutionService.test.ts | 168 +++++++++--------- .../agentSendProvenance.test.ts | 83 ++++++++- .../orchestration-v2/agentSendProvenance.ts | 35 +++- .../codex_transcript.ndjson | 6 +- packages/provider-grok/package.json | 4 + packages/provider-pi/package.json | 4 + 12 files changed, 307 insertions(+), 127 deletions(-) create mode 100644 apps/server/src/orchestration-v2/Adapters/CodexGoalCommand.ts 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/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 21c9943b41191..0bf204c5f27e7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1,6 +1,7 @@ import { revertCodexThread } from "../../provider/CodexThreadRevert.ts"; import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; +import { codexGoalRoute, type CodexGoalCommand } from "./CodexGoalCommand.ts"; import { mcpToolPresentation, type McpToolPresentation, @@ -881,31 +882,7 @@ const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean => turn.status === "failed" || turn.status === "cancelled"); -export type CodexGoalCommand = - | { readonly type: "show" } - | { readonly type: "clear" } - | { readonly type: "pause" } - | { readonly type: "resume" } - | { readonly type: "set"; readonly objective: string }; - -function parseCodexGoalCommand(text: string): CodexGoalCommand | null { - const match = /^\/goal(?:\s+([\s\S]*))?$/u.exec(text.trim()); - if (match === null) return null; - const argument = (match[1] ?? "").trim(); - switch (argument.toLowerCase()) { - case "": - case "edit": - return { type: "show" }; - case "clear": - return { type: "clear" }; - case "pause": - return { type: "pause" }; - case "resume": - return { type: "resume" }; - default: - return { type: "set", objective: argument }; - } -} +export type { CodexGoalCommand } from "./CodexGoalCommand.ts"; type CodexThreadGoal = CodexSchema.V2ThreadGoalUpdatedNotification["goal"]; @@ -6754,11 +6731,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), ), startTurn: (turnInput) => { - const goalCommand = - turnInput.message.attachments.length === 0 && - turnInput.restartContinuationOfRunId === undefined - ? parseCodexGoalCommand(turnInput.message.text) - : null; + const goalCommand = codexGoalRoute( + turnInput.message.text, + turnInput.message.attachments, + turnInput.restartContinuationOfRunId !== undefined, + ); if (goalCommand !== null) return runGoalCommand(turnInput, goalCommand); return Effect.gen(function* () { yield* startNativeTurn( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexGoalCommand.ts b/apps/server/src/orchestration-v2/Adapters/CodexGoalCommand.ts new file mode 100644 index 0000000000000..624ed0381661f --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/CodexGoalCommand.ts @@ -0,0 +1,35 @@ +export type CodexGoalCommand = + | { readonly type: "show" } + | { readonly type: "clear" } + | { readonly type: "pause" } + | { readonly type: "resume" } + | { readonly type: "set"; readonly objective: string }; + +export function parseCodexGoalCommand(text: string): CodexGoalCommand | null { + const match = /^\/goal(?:\s+([\s\S]*))?$/u.exec(text.trim()); + if (match === null) return null; + const argument = (match[1] ?? "").trim(); + switch (argument.toLowerCase()) { + case "": + case "edit": + return { type: "show" }; + case "clear": + return { type: "clear" }; + case "pause": + return { type: "pause" }; + case "resume": + return { type: "resume" }; + default: + return { type: "set", objective: argument }; + } +} + +/** Same decision `CodexAdapterV2.startTurn` uses before it calls the native goal operation. */ +export function codexGoalRoute( + text: string, + attachments: ReadonlyArray, + isRestartContinuation: boolean, +): CodexGoalCommand | null { + if (attachments.length > 0 || isRestartContinuation) return null; + return parseCodexGoalCommand(text); +} diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index f64491de9ed4d..46fe62066fb8d 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -351,6 +351,7 @@ export const layer: Layer.Layer< text: message.text, records: message.context?.records ?? [], }), + attachments: message.attachments, createdBy: message.createdBy, senderThreadId: message.senderThreadId, readSenderTitle: (senderThreadId) => diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index bc863ad87c81a..0e7fa970776df 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -284,6 +284,7 @@ function makeLocalCommandHarness(input: { readonly subagents?: OrchestrationV2ThreadProjection["subagents"]; readonly senderTitles?: Readonly>; readonly senderLookupFails?: boolean; + readonly handlePromptCommand?: boolean; }) { const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); const threadId = ThreadId.make("thread-native-account-command"); @@ -635,15 +636,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 @@ -1347,3 +1350,27 @@ effectIt.effect.each([ 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" }, +])("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 5bd3866a9d41f..e692bc534ed71 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1031,8 +1031,10 @@ export const layer: Layer.Layer< text: message.text, records: message.context?.records ?? [], }), + attachments: message.attachments, createdBy: message.createdBy, senderThreadId: message.senderThreadId, + restartContinuation: run.restartContinuationOfRunId !== undefined, readSenderTitle: (senderThreadId) => projectionStore.getThread(senderThreadId).pipe(Effect.map((thread) => thread.title)), readReceiverSubagents: () => Effect.succeed(projection.subagents), diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 03bbbf0506b50..a90ad4789a45a 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -45,6 +45,7 @@ import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter" import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts"; +import { providerTextForTurn } from "./agentSendProvenance.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; import * as RunFinalizationService from "./RunFinalizationService.ts"; import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; @@ -1011,87 +1012,94 @@ 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", + attachments: [], + 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 index f78a3ef564148..bd94e61f66186 100644 --- a/apps/server/src/orchestration-v2/agentSendProvenance.test.ts +++ b/apps/server/src/orchestration-v2/agentSendProvenance.test.ts @@ -1,20 +1,33 @@ import { assert, it } from "@effect/vitest"; import { 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 { codexGoalRoute } from "./Adapters/CodexGoalCommand.ts"; import { providerTextForTurn } from "./agentSendProvenance.ts"; const senderThreadId = ThreadId.make("thread:child-review"); const body = "Please merge the PR now.\n"; -it.effect("keeps the original text after one header line", () => +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, + attachments: [], createdBy: "agent", senderThreadId, - readSenderTitle: () => Effect.succeed('Review\n"the" PR'), - readReceiverSubagents: () => Effect.succeed([]), + ...lookups, }); assert.equal(text, `Agent-authored message from "Review 'the' PR", not the user.\n${body}`); @@ -25,6 +38,7 @@ it.effect("uses the thread id when the title is blank", () => Effect.gen(function* () { const text = yield* providerTextForTurn({ text: body, + attachments: [], createdBy: "agent", senderThreadId, readSenderTitle: () => Effect.succeed(" \n\t "), @@ -39,6 +53,7 @@ it.effect("leaves a system message unchanged", () => Effect.gen(function* () { const text = yield* providerTextForTurn({ text: body, + attachments: [], createdBy: "system", senderThreadId, readSenderTitle: () => Effect.die("system messages do not look up a sender"), @@ -48,3 +63,65 @@ it.effect("leaves a system message unchanged", () => assert.equal(text, body); }), ); + +it.effect("an agent /compact still compacts", () => + Effect.gen(function* () { + const text = yield* providerTextForTurn({ + text: " /compact ", + attachments: [], + 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", + attachments: [], + createdBy: "agent", + senderThreadId, + ...untouched, + }); + + assert.deepEqual(codexGoalRoute(text, [], false), { 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", + attachments: [], + createdBy: "agent", + senderThreadId, + ...untouched, + }); + + assert.deepEqual(leadingGrokSkillMention(text), { name: "deploy", rest: " ship it" }); + }), +); + +it.effect("still headers prose that only looks like a command or a price", () => + Effect.gen(function* () { + for (const text of ["/please review this", "$25 off"]) { + const providerText = yield* providerTextForTurn({ + text, + attachments: [], + createdBy: "agent", + senderThreadId, + ...lookups, + }); + assert.equal( + providerText, + `Agent-authored message from "Review 'the' PR", not the user.\n${text}`, + ); + } + }), +); diff --git a/apps/server/src/orchestration-v2/agentSendProvenance.ts b/apps/server/src/orchestration-v2/agentSendProvenance.ts index 64dba4ebcc933..6e41b5d8a949a 100644 --- a/apps/server/src/orchestration-v2/agentSendProvenance.ts +++ b/apps/server/src/orchestration-v2/agentSendProvenance.ts @@ -1,7 +1,17 @@ -import type { OrchestrationV2Actor, OrchestrationV2Subagent, ThreadId } from "@t3tools/contracts"; +import type { + ChatAttachment, + OrchestrationV2Actor, + OrchestrationV2Subagent, + 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 * as Option from "effect/Option"; +import { codexGoalRoute } from "./Adapters/CodexGoalCommand.ts"; +import { isNativeMaintenanceCommand } from "./CommandPolicy.ts"; + /** * 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 @@ -9,15 +19,21 @@ import * as Option from "effect/Option"; */ export function providerTextForTurn(input: { readonly text: string; + readonly attachments: ReadonlyArray; readonly createdBy: OrchestrationV2Actor; readonly senderThreadId?: ThreadId | undefined; + readonly restartContinuation?: boolean; readonly readSenderTitle: (senderThreadId: ThreadId) => Effect.Effect; readonly readReceiverSubagents: () => Effect.Effect< ReadonlyArray>, SubagentError >; }): Effect.Effect { - if (input.createdBy !== "agent" || input.senderThreadId === undefined) { + if ( + input.createdBy !== "agent" || + input.senderThreadId === undefined || + preservesNativeInvocation(input) + ) { return Effect.succeed(input.text); } const senderThreadId = input.senderThreadId; @@ -36,6 +52,21 @@ export function providerTextForTurn(input: { }); } +// Command and skill handlers match this provider text, so a header would hide the invocation. +function preservesNativeInvocation(input: { + readonly text: string; + readonly attachments: ReadonlyArray; + readonly restartContinuation?: boolean; +}): boolean { + if (leadingGrokSkillMention(input.text) !== undefined) return true; + if (parsePiCompactCommand(input.text) !== null) return true; + if (input.attachments.length > 0) return false; + return ( + isNativeMaintenanceCommand(input) || + codexGoalRoute(input.text, input.attachments, input.restartContinuation === true) !== null + ); +} + function senderName(title: string | undefined, senderThreadId: ThreadId): string { if (title === undefined) return senderThreadId; const singleLine = title.replaceAll(/\s+/g, " ").replaceAll('"', "'").trim(); 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-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" From 5f5e9eabf76bfd1b8fb6e14692ea3a33ba22f412 Mon Sep 17 00:00:00 2001 From: Wes Hines Date: Sun, 25 Oct 2026 00:06:34 -0500 Subject: [PATCH 3/7] fix(server): scope provenance exemptions to the receiving driver Discovered slash commands and enabled skills stay raw only for the driver that would invoke them. Ordinary lookalikes still get a sender line, and a skill token in the title no longer dispatches. Model: Grok 4.7 Co-authored-by: Cursor --- .../Adapters/ClaudeAdapterV2.ts | 8 + .../Adapters/CodexAdapterV2.ts | 37 ++- .../Adapters/CodexGoalCommand.ts | 35 --- .../Adapters/OpenCode2AdapterV2.test.ts | 68 ++++++ .../ProviderTurnControlService.test.ts | 65 ++++- .../ProviderTurnControlService.ts | 11 + .../ProviderTurnStartService.test.ts | 69 +++++- .../ProviderTurnStartService.ts | 10 + .../RunExecutionService.test.ts | 1 + .../agentSendProvenance.test.ts | 225 +++++++++++++++++- .../orchestration-v2/agentSendProvenance.ts | 114 +++++++-- packages/provider-acp/src/server/adapter.ts | 32 +++ .../src/server/ProviderAdapter.ts | 5 + packages/provider-grok/src/server/adapter.ts | 7 + .../src/server/adapter.test.ts | 119 +++++++++ .../provider-opencode/src/server/adapter.ts | 9 + .../src/server/v2/adapter.ts | 13 + .../provider-pi/src/server/adapter.test.ts | 38 +++ packages/provider-pi/src/server/adapter.ts | 33 ++- 19 files changed, 813 insertions(+), 86 deletions(-) delete mode 100644 apps/server/src/orchestration-v2/Adapters/CodexGoalCommand.ts diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 9dea43bf9e710..a15d9c7d3c1c4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -10087,6 +10087,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( getModelContextWindow: (selection) => resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, selection), events: Stream.fromEffectRepeat(Queue.take(events)), + nativeInvocationSurface: (surfaceInput) => + userInvocableSkills(surfaceInput.cwd ?? null).pipe( + Effect.map((skills) => ({ + slashCommands: [...skills.keys()].map((name) => ({ name })), + skills: [...skills.keys()].map((name) => ({ name, enabled: true })), + })), + Effect.orElseSucceed(() => ({ slashCommands: [], skills: [] })), + ), backgroundTasksEndWithProcess: true, hasPendingBackgroundWork: Effect.gen(function* () { for (const roster of (yield* Ref.get(pendingBackgroundTasksByNativeThread)).values()) { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 0bf204c5f27e7..904ee57c631af 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1,7 +1,6 @@ import { revertCodexThread } from "../../provider/CodexThreadRevert.ts"; import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; -import { codexGoalRoute, type CodexGoalCommand } from "./CodexGoalCommand.ts"; import { mcpToolPresentation, type McpToolPresentation, @@ -882,7 +881,31 @@ const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean => turn.status === "failed" || turn.status === "cancelled"); -export type { CodexGoalCommand } from "./CodexGoalCommand.ts"; +export type CodexGoalCommand = + | { readonly type: "show" } + | { readonly type: "clear" } + | { readonly type: "pause" } + | { readonly type: "resume" } + | { readonly type: "set"; readonly objective: string }; + +export function parseCodexGoalCommand(text: string): CodexGoalCommand | null { + const match = /^\/goal(?:\s+([\s\S]*))?$/u.exec(text.trim()); + if (match === null) return null; + const argument = (match[1] ?? "").trim(); + switch (argument.toLowerCase()) { + case "": + case "edit": + return { type: "show" }; + case "clear": + return { type: "clear" }; + case "pause": + return { type: "pause" }; + case "resume": + return { type: "resume" }; + default: + return { type: "set", objective: argument }; + } +} type CodexThreadGoal = CodexSchema.V2ThreadGoalUpdatedNotification["goal"]; @@ -6731,11 +6754,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), ), startTurn: (turnInput) => { - const goalCommand = codexGoalRoute( - turnInput.message.text, - turnInput.message.attachments, - turnInput.restartContinuationOfRunId !== undefined, - ); + const goalCommand = + turnInput.message.attachments.length === 0 && + turnInput.restartContinuationOfRunId === undefined + ? parseCodexGoalCommand(turnInput.message.text) + : null; if (goalCommand !== null) return runGoalCommand(turnInput, goalCommand); return Effect.gen(function* () { yield* startNativeTurn( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexGoalCommand.ts b/apps/server/src/orchestration-v2/Adapters/CodexGoalCommand.ts deleted file mode 100644 index 624ed0381661f..0000000000000 --- a/apps/server/src/orchestration-v2/Adapters/CodexGoalCommand.ts +++ /dev/null @@ -1,35 +0,0 @@ -export type CodexGoalCommand = - | { readonly type: "show" } - | { readonly type: "clear" } - | { readonly type: "pause" } - | { readonly type: "resume" } - | { readonly type: "set"; readonly objective: string }; - -export function parseCodexGoalCommand(text: string): CodexGoalCommand | null { - const match = /^\/goal(?:\s+([\s\S]*))?$/u.exec(text.trim()); - if (match === null) return null; - const argument = (match[1] ?? "").trim(); - switch (argument.toLowerCase()) { - case "": - case "edit": - return { type: "show" }; - case "clear": - return { type: "clear" }; - case "pause": - return { type: "pause" }; - case "resume": - return { type: "resume" }; - default: - return { type: "set", objective: argument }; - } -} - -/** Same decision `CodexAdapterV2.startTurn` uses before it calls the native goal operation. */ -export function codexGoalRoute( - text: string, - attachments: ReadonlyArray, - isRestartContinuation: boolean, -): CodexGoalCommand | null { - if (attachments.length > 0 || isRestartContinuation) return null; - return parseCodexGoalCommand(text); -} diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index ca0288ab2c5e6..c5037a9fadb36 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 "../agentSendProvenance.ts"; 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"; @@ -2167,6 +2168,73 @@ 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", + attachments: [], + createdBy: "agent", + senderThreadId: ThreadId.make("thread:child-review"), + driver: OPENCODE_PROVIDER, + nativeSurface: { slashCommands: [{ name: "hello" }], skills: [] }, + 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 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 841a038c4ba32..75edac977f080 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -1,5 +1,7 @@ import { assert, it } from "@effect/vitest"; import { + ChatAttachmentId, + ChatFileAttachment, MessageId, type ModelSelection, NodeId, @@ -354,6 +356,13 @@ function steerProviderText(input: { readonly senderThreadId?: ThreadId; readonly senderTitle?: string; readonly senderLookupFails?: boolean; + readonly text?: string; + readonly attachments?: OrchestrationV2ConversationMessage["attachments"]; + readonly driver?: ProviderDriverKind; + readonly nativeSurface?: { + readonly slashCommands: ReadonlyArray<{ readonly name: string }>; + readonly skills: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>; + }; readonly subagents?: ReadonlyArray<{ readonly origin: "app_owned" | "provider_native"; readonly childThreadId: ThreadId | null; @@ -367,15 +376,16 @@ function steerProviderText(input: { const providerThreadId = ProviderThreadId.make("provider-thread:steer-provenance"); const providerTurnId = ProviderTurnId.make("provider-turn:steer-provenance"); const messageId = MessageId.make("message:steer-provenance"); + const sessionDriver = input.driver ?? driver; const providerThread: OrchestrationV2ProviderThread = { id: providerThreadId, - driver, + driver: sessionDriver, providerInstanceId, providerSessionId, appThreadId: threadId, ownerNodeId: null, nativeThreadRef: { - driver, + driver: sessionDriver, nativeId: "native-thread:steer-provenance", strength: "strong", }, @@ -394,8 +404,8 @@ function steerProviderText(input: { runId: RunId.make("run:steer-provenance"), nodeId: NodeId.make("node:steer-provenance"), role: "user", - text: steerBody, - attachments: [], + text: input.text ?? steerBody, + attachments: input.attachments ?? [], streaming: false, createdBy: input.createdBy, creationSource: input.creationSource, @@ -406,11 +416,14 @@ function steerProviderText(input: { const steered = yield* Ref.make(undefined); const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: providerInstanceId, - driver, + driver: sessionDriver, + ...(input.nativeSurface === undefined + ? {} + : { nativeInvocationSurface: () => Effect.succeed(input.nativeSurface!) }), providerSessionId, providerSession: { id: providerSessionId, - driver, + driver: sessionDriver, providerInstanceId, status: "running", cwd: "/workspace", @@ -565,6 +578,46 @@ it.effect.each([ creationSource: "server" as const, expected: steerBody, }, + { + label: "a Codex /compact with a file is an ordinary steer", + createdBy: "agent" as const, + creationSource: "mcp" as const, + senderThreadId: steerChildThreadId, + senderTitle: "Review the PR", + text: "/compact", + attachments: [ + ChatFileAttachment.make({ + type: "file", + id: ChatAttachmentId.make("compact-file-12345678-1234-1234-1234-123456789abc"), + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 1, + }), + ], + expected: `Agent-authored message from "Review the PR", not the user.\n/compact`, + }, + { + label: "an unknown Grok skill is an ordinary steer", + createdBy: "agent" as const, + creationSource: "mcp" as const, + senderThreadId: steerChildThreadId, + senderTitle: "Review the PR", + text: "$HOME is unset; use the fallback.", + driver: ProviderDriverKind.make("grok"), + nativeSurface: { slashCommands: [], skills: [] }, + expected: `Agent-authored message from "Review the PR", not the user.\n$HOME is unset; use the fallback.`, + }, + { + label: "a disabled Grok skill is an ordinary steer", + createdBy: "agent" as const, + creationSource: "mcp" as const, + senderThreadId: steerChildThreadId, + senderTitle: "Review the PR", + text: "$deploy ship it", + driver: ProviderDriverKind.make("grok"), + nativeSurface: { slashCommands: [], skills: [{ name: "deploy", enabled: false }] }, + expected: `Agent-authored message from "Review the PR", not the user.\n$deploy ship it`, + }, ])("steers provider text so $label", (input) => Effect.gen(function* () { const text = yield* steerProviderText(input); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 46fe62066fb8d..1b0e2e7266edf 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -346,6 +346,15 @@ export const layer: Layer.Layer< cause: "The persisted steering message or target run is missing.", }); } + const runtime = loaded.session.value; + const nativeSurface = runtime.nativeInvocationSurface + ? yield* runtime.nativeInvocationSurface({ cwd: runtime.providerSession.cwd }).pipe( + Effect.orElseSucceed(() => ({ + slashCommands: [], + skills: [], + })), + ) + : { slashCommands: [], skills: [] }; const text = yield* providerTextForTurn({ text: projectComposerContextForProvider({ text: message.text, @@ -354,6 +363,8 @@ export const layer: Layer.Layer< attachments: message.attachments, createdBy: message.createdBy, senderThreadId: message.senderThreadId, + driver: runtime.driver, + nativeSurface, readSenderTitle: (senderThreadId) => projections.getThread(senderThreadId).pipe(Effect.map((thread) => thread.title)), readReceiverSubagents: () => diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 0e7fa970776df..4bc69c27d8ba6 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -3,6 +3,8 @@ import { expect, it, vi } from "vite-plus/test"; import { it as effectIt } from "@effect/vitest"; import { CheckpointScopeId, + ChatAttachmentId, + ChatFileAttachment, MessageId, NodeId, ProviderSessionId, @@ -285,6 +287,12 @@ function makeLocalCommandHarness(input: { readonly senderTitles?: Readonly>; readonly senderLookupFails?: boolean; readonly handlePromptCommand?: boolean; + readonly attachments?: OrchestrationV2ThreadProjection["messages"][number]["attachments"]; + readonly driver?: ProviderDriverKind; + readonly nativeSurface?: { + readonly slashCommands: ReadonlyArray<{ readonly name: string }>; + readonly skills: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>; + }; }) { const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); const threadId = ThreadId.make("thread-native-account-command"); @@ -317,7 +325,7 @@ function makeLocalCommandHarness(input: { }; const providerThread: OrchestrationV2ThreadProjection["providerThreads"][number] = { id: providerThreadId, - driver: ProviderDriverKind.make("codex"), + driver: input.driver ?? ProviderDriverKind.make("codex"), providerInstanceId: newInstanceId, providerSessionId, appThreadId: threadId, @@ -339,7 +347,7 @@ function makeLocalCommandHarness(input: { nodeId: rootNodeId, role: "user", text: input.text, - attachments: [], + attachments: input.attachments ?? [], streaming: false, createdBy: input.createdBy ?? "user", creationSource: input.creationSource ?? "web", @@ -615,6 +623,11 @@ function makeLocalCommandHarness(input: { }, ensureThread: () => Effect.succeed(providerThread), resumeThread: () => Effect.succeed(projection.providerThreads[0]), + ...(input.nativeSurface === undefined + ? {} + : { + nativeInvocationSurface: () => Effect.succeed(input.nativeSurface), + }), } as never) : Effect.die("A local command must not open a native session."), ); @@ -1374,3 +1387,55 @@ effectIt.effect.each([ expect(harness.tryHandlePromptCommand).toHaveBeenCalled(); }), ); + +const compactFile = ChatFileAttachment.make({ + type: "file", + id: ChatAttachmentId.make("compact-file-12345678-1234-1234-1234-123456789abc"), + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 1, +}); + +effectIt.effect.each([ + { + label: "a Codex /compact with a file is an ordinary prompt", + text: "/compact", + attachments: [compactFile], + driver: ProviderDriverKind.make("codex"), + }, + { + label: "an unknown Grok skill is ordinary text", + text: "$HOME is unset; use the fallback.", + driver: ProviderDriverKind.make("grok"), + nativeSurface: { slashCommands: [], skills: [] }, + }, + { + label: "a disabled Grok skill is ordinary text", + text: "$deploy ship it", + driver: ProviderDriverKind.make("grok"), + nativeSurface: { slashCommands: [], skills: [{ name: "deploy", enabled: false }] }, + }, +])("assembles provider text so $label", (input) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: input.text, + attachments: "attachments" in input ? input.attachments : [], + previousMessages: ["Earlier turn"], + previousTurn: settledPreviousTurn, + createdBy: "agent", + creationSource: "mcp", + senderThreadId: childThreadId, + senderTitles: { [childThreadId]: "Review the PR" }, + driver: input.driver, + ...("nativeSurface" in input ? { nativeSurface: input.nativeSurface } : {}), + handlePromptCommand: false, + }); + + yield* harness.start; + + expect(harness.projection().messages.at(-1)?.text).toBe(input.text); + expect(harness.startRootRun.mock.calls[0]?.[0].message.text).toBe( + `Agent-authored message from "Review the PR", not the user.\n${input.text}`, + ); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index e692bc534ed71..ab8ea0a26b36d 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1026,6 +1026,14 @@ export const layer: Layer.Layer< const routableSubagents = projection.subagents.filter((subagent) => RunExecutionService.canRouteRelatedSubagent(subagent.status), ); + const nativeSurface = session.nativeInvocationSurface + ? yield* session.nativeInvocationSurface({ cwd: resolvedRuntimePolicy.cwd }).pipe( + Effect.orElseSucceed(() => ({ + slashCommands: [], + skills: [], + })), + ) + : { slashCommands: [], skills: [] }; const userText = yield* providerTextForTurn({ text: projectComposerContextForProvider({ text: message.text, @@ -1035,6 +1043,8 @@ export const layer: Layer.Layer< createdBy: message.createdBy, senderThreadId: message.senderThreadId, restartContinuation: run.restartContinuationOfRunId !== undefined, + driver: session.driver, + nativeSurface, readSenderTitle: (senderThreadId) => projectionStore.getThread(senderThreadId).pipe(Effect.map((thread) => thread.title)), readReceiverSubagents: () => Effect.succeed(projection.subagents), diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index a90ad4789a45a..ba3bfb453505d 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1028,6 +1028,7 @@ it.effect("an agent /compact still compacts", () => attachments: [], createdBy: "agent", senderThreadId: ThreadId.make("thread:agent-compact-sender"), + driver, readSenderTitle: () => Effect.die("commands do not look up a sender"), readReceiverSubagents: () => Effect.die("commands do not look up a sender"), }); diff --git a/apps/server/src/orchestration-v2/agentSendProvenance.test.ts b/apps/server/src/orchestration-v2/agentSendProvenance.test.ts index bd94e61f66186..8c57ff472dc8d 100644 --- a/apps/server/src/orchestration-v2/agentSendProvenance.test.ts +++ b/apps/server/src/orchestration-v2/agentSendProvenance.test.ts @@ -1,14 +1,43 @@ import { assert, it } from "@effect/vitest"; -import { ThreadId } from "@t3tools/contracts"; +import { + ChatAttachmentId, + ChatFileAttachment, + ProviderDriverKind, + 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 { codexGoalRoute } from "./Adapters/CodexGoalCommand.ts"; -import { providerTextForTurn } from "./agentSendProvenance.ts"; +import { parseCodexGoalCommand } from "./Adapters/CodexAdapterV2.ts"; +import { providerTextForTurn, type ReceiverNativeSurface } from "./agentSendProvenance.ts"; +import { planClaudeSkillDispatch } from "../provider/Drivers/ClaudeSkillDispatch.ts"; const senderThreadId = ThreadId.make("thread:child-review"); const body = "Please merge the PR now.\n"; +const codex = ProviderDriverKind.make("codex"); +const grok = ProviderDriverKind.make("grok"); +const pi = ProviderDriverKind.make("pi"); +const claude = ProviderDriverKind.make("claude"); +const opencode = ProviderDriverKind.make("opencode"); +const header = (text: string, name = `"Review 'the' PR"`) => + `Agent-authored message from ${name}, not the user.\n${text}`; +const file = ChatFileAttachment.make({ + type: "file", + id: ChatAttachmentId.make("compact-file-12345678-1234-1234-1234-123456789abc"), + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 1, +}); +const deploySkill = { + slashCommands: [], + skills: [{ name: "deploy", enabled: true }], +} satisfies ReceiverNativeSurface; +const disabledDeploy = { + slashCommands: [], + skills: [{ name: "deploy", enabled: false }], +} satisfies ReceiverNativeSurface; +const claudeSkills = new Map([["deploy", { path: "/skills/deploy/SKILL.md" }]]); const lookups = { readSenderTitle: () => Effect.succeed('Review\n"the" PR'), @@ -27,10 +56,11 @@ it.effect("an ordinary agent message still gets the header", () => attachments: [], createdBy: "agent", senderThreadId, + driver: codex, ...lookups, }); - assert.equal(text, `Agent-authored message from "Review 'the' PR", not the user.\n${body}`); + assert.equal(text, header(body)); }), ); @@ -41,11 +71,12 @@ it.effect("uses the thread id when the title is blank", () => attachments: [], createdBy: "agent", senderThreadId, + driver: codex, readSenderTitle: () => Effect.succeed(" \n\t "), readReceiverSubagents: () => Effect.succeed([]), }); - assert.equal(text, `Agent-authored message from ${senderThreadId}, not the user.\n${body}`); + assert.equal(text, header(body, senderThreadId)); }), ); @@ -56,6 +87,7 @@ it.effect("leaves a system message unchanged", () => attachments: [], createdBy: "system", senderThreadId, + driver: codex, readSenderTitle: () => Effect.die("system messages do not look up a sender"), readReceiverSubagents: () => Effect.die("system messages do not look up a sender"), }); @@ -71,6 +103,7 @@ it.effect("an agent /compact still compacts", () => attachments: [], createdBy: "agent", senderThreadId, + driver: codex, ...untouched, }); @@ -87,10 +120,11 @@ it.effect("an agent /goal x still routes", () => attachments: [], createdBy: "agent", senderThreadId, + driver: codex, ...untouched, }); - assert.deepEqual(codexGoalRoute(text, [], false), { type: "set", objective: "x" }); + assert.deepEqual(parseCodexGoalCommand(text), { type: "set", objective: "x" }); }), ); @@ -101,6 +135,8 @@ it.effect("an agent $deploy ship it still matches the Grok skill parser", () => attachments: [], createdBy: "agent", senderThreadId, + driver: grok, + nativeSurface: deploySkill, ...untouched, }); @@ -116,12 +152,181 @@ it.effect("still headers prose that only looks like a command or a price", () => attachments: [], createdBy: "agent", senderThreadId, + driver: codex, ...lookups, }); - assert.equal( - providerText, - `Agent-authored message from "Review 'the' PR", not the user.\n${text}`, - ); + assert.equal(providerText, header(text)); } }), ); + +it.effect("headers unknown and disabled Grok skills, including on other drivers", () => + Effect.gen(function* () { + const cases = [ + { driver: grok, text: "$HOME is unset; use the fallback.", nativeSurface: undefined }, + { driver: grok, text: "$deploy ship it", nativeSurface: disabledDeploy }, + { driver: codex, text: "$HOME is unset; use the fallback.", nativeSurface: deploySkill }, + { driver: codex, text: "$deploy ship it", nativeSurface: deploySkill }, + ] as const; + for (const input of cases) { + const providerText = yield* providerTextForTurn({ + text: input.text, + attachments: [], + createdBy: "agent", + senderThreadId, + driver: input.driver, + ...(input.nativeSurface === undefined ? {} : { nativeSurface: input.nativeSurface }), + ...lookups, + }); + assert.equal(providerText, header(input.text)); + } + }), +); + +it.effect("headers Codex /compact when a file is attached, and Pi compact only on Pi", () => + Effect.gen(function* () { + const withFile = yield* providerTextForTurn({ + text: "/compact", + attachments: [file], + createdBy: "agent", + senderThreadId, + driver: codex, + ...lookups, + }); + assert.equal(withFile, header("/compact")); + + const argued = yield* providerTextForTurn({ + text: "/compact keep API details", + attachments: [], + createdBy: "agent", + senderThreadId, + driver: codex, + ...lookups, + }); + assert.equal(argued, header("/compact keep API details")); + + const piCompact = yield* providerTextForTurn({ + text: "/compact keep API details", + attachments: [file], + createdBy: "agent", + senderThreadId, + driver: pi, + ...untouched, + }); + assert.equal(piCompact, "/compact keep API details"); + }), +); + +it.effect("keeps a discovered command only for the driver that has it", () => + Effect.gen(function* () { + const review = { + slashCommands: [{ name: "review" }], + skills: [], + } satisfies ReceiverNativeSurface; + const kept = yield* providerTextForTurn({ + text: "/review staged changes", + attachments: [], + createdBy: "agent", + senderThreadId, + driver: opencode, + nativeSurface: review, + ...untouched, + }); + assert.equal(kept, "/review staged changes"); + + const unknown = yield* providerTextForTurn({ + text: "/review staged changes", + attachments: [], + createdBy: "agent", + senderThreadId, + driver: opencode, + ...lookups, + }); + assert.equal(unknown, header("/review staged changes")); + + const prose = yield* providerTextForTurn({ + text: "/please review this", + attachments: [], + createdBy: "agent", + senderThreadId, + driver: opencode, + nativeSurface: review, + ...lookups, + }); + assert.equal(prose, header("/please review this")); + }), +); + +it.effect("keeps Pi custom commands and skill invocations", () => + Effect.gen(function* () { + const surface = { + slashCommands: [{ name: "my-command" }], + skills: [{ name: "deploy", enabled: true }], + } satisfies ReceiverNativeSurface; + for (const text of ["/my-command", "/skill:deploy"]) { + const providerText = yield* providerTextForTurn({ + text, + attachments: [], + createdBy: "agent", + senderThreadId, + driver: pi, + nativeSurface: surface, + ...untouched, + }); + assert.equal(providerText, text); + } + const missing = yield* providerTextForTurn({ + text: "/my-command", + attachments: [], + createdBy: "agent", + senderThreadId, + driver: pi, + ...lookups, + }); + assert.equal(missing, header("/my-command")); + }), +); + +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.", + attachments: [], + createdBy: "agent", + senderThreadId, + driver: claude, + readSenderTitle: () => Effect.succeed(title), + readReceiverSubagents: () => Effect.succeed([]), + }); + assert.isUndefined(planClaudeSkillDispatch(text, claudeSkills)); + } + + const intentional = yield* providerTextForTurn({ + text: "Ship $deploy now", + attachments: [], + createdBy: "agent", + senderThreadId, + driver: claude, + readSenderTitle: () => Effect.succeed("Review $deploy job"), + readReceiverSubagents: () => Effect.succeed([]), + }); + const plan = planClaudeSkillDispatch(intentional, claudeSkills); + assert.equal(plan?.skillName, "deploy"); + assert.equal(plan?.commandText, "/deploy now"); + }), +); + +it.effect("a Codex goal is ordinary text for every other driver", () => + Effect.gen(function* () { + const text = yield* providerTextForTurn({ + text: "/goal clear", + attachments: [], + createdBy: "agent", + senderThreadId, + driver: opencode, + ...lookups, + }); + assert.equal(text, header("/goal clear")); + }), +); diff --git a/apps/server/src/orchestration-v2/agentSendProvenance.ts b/apps/server/src/orchestration-v2/agentSendProvenance.ts index 6e41b5d8a949a..c85e9cccde945 100644 --- a/apps/server/src/orchestration-v2/agentSendProvenance.ts +++ b/apps/server/src/orchestration-v2/agentSendProvenance.ts @@ -2,73 +2,137 @@ import type { ChatAttachment, OrchestrationV2Actor, OrchestrationV2Subagent, + ProviderDriverKind, ThreadId, } from "@t3tools/contracts"; +import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { leadingGrokSkillMention } from "@t3tools/provider-grok/server/skills"; import { parsePiCompactCommand } from "@t3tools/provider-pi/server/commands"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { codexGoalRoute } from "./Adapters/CodexGoalCommand.ts"; +import { parseCodexGoalCommand } from "./Adapters/CodexAdapterV2.ts"; import { isNativeMaintenanceCommand } from "./CommandPolicy.ts"; +/** + * Commands and skills the receiving driver would actually invoke. + * An empty surface preserves only that driver's built-in forms. + */ +export interface ReceiverNativeSurface { + readonly slashCommands: ReadonlyArray<{ readonly name: string }>; + readonly skills: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>; +} + +const emptyNativeSurface = { + slashCommands: [], + skills: [], +} as const satisfies ReceiverNativeSurface; + /** * 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 function providerTextForTurn(input: { +export const providerTextForTurn = Effect.fn("providerTextForTurn")(function* < + TitleError, + SubagentError, +>(input: { readonly text: string; readonly attachments: ReadonlyArray; readonly createdBy: OrchestrationV2Actor; readonly senderThreadId?: ThreadId | undefined; readonly restartContinuation?: boolean; + readonly driver: ProviderDriverKind; + readonly nativeSurface?: ReceiverNativeSurface; readonly readSenderTitle: (senderThreadId: ThreadId) => Effect.Effect; readonly readReceiverSubagents: () => Effect.Effect< ReadonlyArray>, SubagentError >; -}): Effect.Effect { +}) { if ( input.createdBy !== "agent" || input.senderThreadId === undefined || preservesNativeInvocation(input) ) { - return Effect.succeed(input.text); + return input.text; } const senderThreadId = input.senderThreadId; - return Effect.gen(function* () { - 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}`; - }); -} + 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}`; +}); + +const SLASH_COMMAND = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/u; -// Command and skill handlers match this provider text, so a header would hide the invocation. function preservesNativeInvocation(input: { readonly text: string; readonly attachments: ReadonlyArray; readonly restartContinuation?: boolean; + readonly driver: ProviderDriverKind; + readonly nativeSurface?: ReceiverNativeSurface; }): boolean { - if (leadingGrokSkillMention(input.text) !== undefined) return true; - if (parsePiCompactCommand(input.text) !== null) return true; - if (input.attachments.length > 0) return false; + const surface = input.nativeSurface ?? emptyNativeSurface; + const driver = input.driver; + if ( + driver === "codex" && + input.attachments.length === 0 && + input.restartContinuation !== true && + parseCodexGoalCommand(input.text) !== null + ) { + return true; + } + if (driver === "pi" && parsePiCompactCommand(input.text) !== null) return true; + if (driver === "grok") { + const mention = leadingGrokSkillMention(input.text); + if ( + mention !== undefined && + surface.skills.some((skill) => skill.enabled && skill.name === mention.name) + ) { + return true; + } + } + if (isNativeMaintenanceCommand(input)) return true; + const command = SLASH_COMMAND.exec(input.text.trim()); + const name = command?.[1]; + if (name === undefined) return false; + // Exact /compact and /logout are the maintenance check. Argument forms are Pi-only. + if (name === "compact" || name === "logout") return false; + if (surface.slashCommands.some((entry) => entry.name === name)) return true; + if ( + driver === "pi" && + name.startsWith("skill:") && + surface.skills.some((skill) => skill.enabled && skill.name === name.slice("skill:".length)) + ) { + return true; + } return ( - isNativeMaintenanceCommand(input) || - codexGoalRoute(input.text, input.attachments, input.restartContinuation === true) !== null + (driver === "claude" || driver === "grok" || driver === "cursor") && + surface.skills.some((skill) => skill.enabled && skill.name === name) ); } +// Skill parsers accept any currency sigil. A word joiner keeps the title from matching. +function neutralizeSkillIntroducers(text: string): string { + return text.replace(new RegExp(SKILL_MENTION_PATTERN.source, "gu"), (match, lead: string) => { + const rest = match.slice(lead.length); + const currency = rest.codePointAt(0); + if (currency === undefined) return match; + const currencyText = String.fromCodePoint(currency); + return `${lead}${currencyText}\u2060${rest.slice(currencyText.length)}`; + }); +} + function senderName(title: string | undefined, senderThreadId: ThreadId): string { if (title === undefined) return senderThreadId; - const singleLine = title.replaceAll(/\s+/g, " ").replaceAll('"', "'").trim(); + const singleLine = neutralizeSkillIntroducers( + title.replaceAll(/\s+/g, " ").replaceAll('"', "'"), + ).trim(); return singleLine.length === 0 ? senderThreadId : `"${singleLine}"`; } diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 3ac79ddc614f6..490e781615866 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -226,6 +226,13 @@ export interface AcpAdapterV2Flavor { text: string, turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, ) => Effect.Effect; + readonly nativeSkills?: ( + cwd?: string | null, + ) => Effect.Effect< + ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>, + never, + ChildProcessSpawner.ChildProcessSpawner + >; readonly driver: ProviderDriverKind; readonly capabilities: OrchestrationV2ProviderCapabilities; readonly clientCapabilitiesMeta?: NonNullable< @@ -1575,6 +1582,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const activeSelection = yield* Ref.make(null); const activeInteractionMode = yield* Ref.make(null); const promptInstructionStates = yield* Ref.make(new Map()); + const availableCommandNames = yield* Ref.make>([]); // Unique markers keep delayed prompt completions from clearing later compactions. const compactionMarkers = yield* Ref.make(new Map()); const runtimeRestartRequired = yield* Ref.make(false); @@ -5758,6 +5766,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( clientTerminals.readOutputSnapshot, ); if (notification.update.sessionUpdate === "available_commands_update") { + yield* Ref.set( + availableCommandNames, + notification.update.availableCommands.map((command) => command.name), + ); yield* ( flavor.onAvailableCommandsUpdate?.(notification.update.availableCommands) ?? Effect.void @@ -7667,6 +7679,26 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const sessionRuntime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: options.instanceId, driver, + nativeInvocationSurface: Effect.fn("AcpAdapterV2.nativeInvocationSurface")( + function* (surfaceInput) { + const names = yield* Ref.get(availableCommandNames); + const skills = + flavor.nativeSkills === undefined + ? [] + : yield* flavor + .nativeSkills(surfaceInput.cwd) + .pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + childProcessSpawner, + ), + ); + return { + slashCommands: names.map((name) => ({ name })), + skills, + }; + }, + ), providerSessionId: input.providerSessionId, providerSession, events: Stream.fromEffectRepeat(Queue.take(events)), diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 7c77b7bb0ac6f..3c114a92d8416 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -578,6 +578,11 @@ export interface ProviderAdapterV2SessionRuntime { readonly startTurn: ( input: ProviderAdapterV2TurnInput, ) => Effect.Effect; + /** Slash commands and skills this session would invoke, from the driver's own catalog. */ + readonly nativeInvocationSurface?: (input: { readonly cwd?: string | null }) => Effect.Effect<{ + readonly slashCommands: ReadonlyArray<{ readonly name: string }>; + readonly skills: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>; + }>; readonly compactThread?: ( input: ProviderAdapterV2TurnInput, ) => Effect.Effect; diff --git a/packages/provider-grok/src/server/adapter.ts b/packages/provider-grok/src/server/adapter.ts index 8fb19014a25cd..27f9b3687f96f 100644 --- a/packages/provider-grok/src/server/adapter.ts +++ b/packages/provider-grok/src/server/adapter.ts @@ -239,6 +239,13 @@ export function makeGrokAcpAdapterFlavor( supportsCompaction: true, // Grok expands only a leading `/name`, while the `$` picker inserts `$name` from the // `grok inspect` catalog. Ask that catalog again so other `$` text is left alone. + nativeSkills: (cwd) => + discoverGrokSkills(options.settings, options.environment, cwd ?? undefined).pipe( + Effect.map((skills) => + skills.map((skill) => ({ name: skill.name, enabled: skill.enabled })), + ), + Effect.orElseSucceed(() => []), + ), rewriteUserText: (text, turnInput) => { const mention = leadingGrokSkillMention(text); if (mention === undefined) return Effect.succeed(text); diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index bc80db9d09cf0..9a018ad838df7 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 "../../../../apps/server/src/orchestration-v2/agentSendProvenance.ts"; import { advanceOpenCodePromptAdmission, cancelOpenCodePromptAdmission, @@ -1122,6 +1123,124 @@ 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 surface = yield* commandHarness.runtime.nativeInvocationSurface!({}); + const commandText = yield* providerTextForTurn({ + text: "/review staged changes", + attachments: [], + createdBy: "agent", + senderThreadId: ThreadId.make("thread:child-review"), + driver: OPENCODE_PROVIDER, + nativeSurface: surface, + 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", + attachments: [], + createdBy: "agent", + senderThreadId: ThreadId.make("thread:child-review"), + driver: OPENCODE_PROVIDER, + nativeSurface: surface, + readSenderTitle: () => Effect.succeed("Review the PR"), + readReceiverSubagents: () => Effect.succeed([]), + }); + yield* proseHarness.startTurn(prose); + assert.equal( + prompted, + `Agent-authored message from "Review the PR", not the user.\n/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-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index d872b723df793..2d157cd9bb55b 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -2974,6 +2974,15 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function providerSession: sessionEntity, events: Stream.fromEffectRepeat(Queue.take(events)), hasPendingBackgroundWork: Effect.sync(() => busySessionIds.size > 0), + nativeInvocationSurface: () => + OpenCodeRuntime.loadOpenCodeCommands(client).pipe( + Effect.timeout("10 seconds"), + Effect.map((commands) => ({ + slashCommands: commands.map((command) => ({ name: command.name })), + skills: [], + })), + Effect.orElseSucceed(() => ({ slashCommands: [], skills: [] })), + ), ensureThread: Effect.fn("OpencodeAdapter.ensureThread")( function* (threadInput) { if (threadInput.existingProviderThread?.nativeThreadRef != null) { diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index dba02e02fa0b1..ce7ea2a74e3f9 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -3336,6 +3336,19 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr events: Stream.fromQueue(events), // Idle release must wait for background children and held wakes after parent settlement. hasPendingBackgroundWork: Effect.sync(() => [...threads.values()].some(owesWork)), + nativeInvocationSurface: (surfaceInput) => + client.command + .list({ + location: { directory: surfaceInput.cwd ?? host.paths.cwd }, + }) + .pipe( + Effect.timeout(INVENTORY_TIMEOUT), + Effect.map((list) => ({ + slashCommands: list.data.map((command) => ({ name: command.name })), + skills: [], + })), + Effect.orElseSucceed(() => ({ slashCommands: [], skills: [] })), + ), hasPendingBackgroundWorkForThread: (providerThread) => Effect.sync(() => { const nativeId = providerThread.nativeThreadRef?.nativeId; diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index f90ebbaba1e88..1eb92f1976afd 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 "../../../../apps/server/src/orchestration-v2/agentSendProvenance.ts"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { @@ -164,6 +165,7 @@ interface FakePi { readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; readonly deferNextModelSelection: () => void; readonly queueModels: (models: ReadonlyArray) => void; + readonly queueCommands: (data: unknown) => void; readonly vetoNextNewSession: () => void; readonly allRequests: () => ReadonlyArray; readonly queueStats: (data: unknown) => void; @@ -202,6 +204,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let vetoSwitch = false; let vetoNewSession = false; let deferredLifecycle: string | undefined; + let commandsData: unknown = { commands: [] }; let sessionFile = FAKE_SESSION_FILE; let sessionGeneration = 0; let models: ReadonlyArray = []; @@ -252,6 +255,8 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { return base; case "get_available_models": return { ...base, data: { models } }; + case "get_commands": + return { ...base, data: commandsData }; case "new_session": { const cancelled = vetoNewSession; vetoNewSession = false; @@ -372,6 +377,9 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { queueModels: (value) => { models = value; }, + queueCommands: (data) => { + commandsData = data; + }, vetoNextNewSession: () => { vetoNewSession = true; }, @@ -518,6 +526,36 @@ 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; + fake.queueCommands({ + commands: [{ name: "my-command", description: "Custom", source: "extension" }], + }); + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const surface = yield* runtime.nativeInvocationSurface!({}); + const text = yield* providerTextForTurn({ + text: "/my-command", + attachments: [], + createdBy: "agent", + senderThreadId: ThreadId.make("thread:child-review"), + driver: PI_PROVIDER, + nativeSurface: surface, + 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.each([false, true])( "completes a resumed user turn before native wake events arrive, Pi streaming=%s", (streaming) => diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 38b6681ec2f23..68f31009d6841 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -42,7 +42,12 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; -import { parsePiCompactCommand, type PiCompactCommand } from "./commands.ts"; +import { + parsePiCompactCommand, + parsePiDiscoveredCommands, + withPiBuiltinSlashCommands, + type PiCompactCommand, +} from "./commands.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -2320,6 +2325,12 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return { message, images }; }); + let piCommandSurface: + | { + readonly slashCommands: ReadonlyArray<{ readonly name: string }>; + readonly skills: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>; + } + | undefined; const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: options.instanceId, driver: PI_PROVIDER, @@ -2329,6 +2340,26 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }, events: Stream.fromQueue(events), hasPendingBackgroundWork: Effect.sync(() => pendingWake !== null), + nativeInvocationSurface: () => { + if (piCommandSurface !== undefined) return Effect.succeed(piCommandSurface); + return request({ type: "get_commands" }).pipe( + Effect.map((data) => { + const discovered = parsePiDiscoveredCommands(data); + const surface = { + slashCommands: withPiBuiltinSlashCommands(discovered.slashCommands).map( + (command) => ({ name: command.name }), + ), + skills: discovered.skills.map((skill) => ({ + name: skill.name, + enabled: skill.enabled, + })), + }; + piCommandSurface = surface; + return surface; + }), + Effect.orElseSucceed(() => ({ slashCommands: [], skills: [] })), + ); + }, hasPendingBackgroundWorkForThread: (providerThread) => Effect.sync( () => pendingWake !== null && pendingWake.state.providerThread.id === providerThread.id, From eb88dc368230fbf103f9346f710ce07d82141207 Mon Sep 17 00:00:00 2001 From: Wes Hines Date: Sun, 25 Oct 2026 00:31:49 -0500 Subject: [PATCH 4/7] fix(server): keep a leading slash or skill token raw Catalog lookups kept missing a driver's commands and delayed ordinary turns. Any agent message that starts with native syntax is sent unchanged, and every other agent message gets the sender line. Model: Grok 4.7 Co-authored-by: Cursor --- .../Adapters/ClaudeAdapterV2.ts | 8 - .../Adapters/OpenCode2AdapterV2.test.ts | 42 +++- .../ProviderTurnControlService.test.ts | 97 +++----- .../ProviderTurnControlService.ts | 12 - .../ProviderTurnStartService.test.ts | 90 ++----- .../ProviderTurnStartService.ts | 12 - .../RunExecutionService.test.ts | 2 - .../agentSendProvenance.test.ts | 232 ++++-------------- .../orchestration-v2/agentSendProvenance.ts | 92 +------ packages/provider-acp/src/server/adapter.ts | 32 --- .../src/server/ProviderAdapter.ts | 5 - packages/provider-grok/src/server/adapter.ts | 7 - .../src/server/adapter.test.ts | 17 +- .../provider-opencode/src/server/adapter.ts | 9 - .../src/server/v2/adapter.ts | 13 - .../provider-pi/src/server/adapter.test.ts | 14 -- packages/provider-pi/src/server/adapter.ts | 33 +-- 17 files changed, 149 insertions(+), 568 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index a15d9c7d3c1c4..9dea43bf9e710 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -10087,14 +10087,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( getModelContextWindow: (selection) => resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, selection), events: Stream.fromEffectRepeat(Queue.take(events)), - nativeInvocationSurface: (surfaceInput) => - userInvocableSkills(surfaceInput.cwd ?? null).pipe( - Effect.map((skills) => ({ - slashCommands: [...skills.keys()].map((name) => ({ name })), - skills: [...skills.keys()].map((name) => ({ name, enabled: true })), - })), - Effect.orElseSucceed(() => ({ slashCommands: [], skills: [] })), - ), backgroundTasksEndWithProcess: true, hasPendingBackgroundWork: Effect.gen(function* () { for (const roster of (yield* Ref.get(pendingBackgroundTasksByNativeThread)).values()) { diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index c5037a9fadb36..c6995944a74ec 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -2212,11 +2212,8 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ]); const text = yield* providerTextForTurn({ text: "/hello WORLD", - attachments: [], createdBy: "agent", senderThreadId: ThreadId.make("thread:child-review"), - driver: OPENCODE_PROVIDER, - nativeSurface: { slashCommands: [{ name: "hello" }], skills: [] }, readSenderTitle: () => Effect.die("commands do not look up a sender"), readReceiverSubagents: () => Effect.die("commands do not look up a sender"), }); @@ -2235,6 +2232,45 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).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 75edac977f080..f86b5f792ce16 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -1,7 +1,5 @@ import { assert, it } from "@effect/vitest"; import { - ChatAttachmentId, - ChatFileAttachment, MessageId, type ModelSelection, NodeId, @@ -358,11 +356,6 @@ function steerProviderText(input: { readonly senderLookupFails?: boolean; readonly text?: string; readonly attachments?: OrchestrationV2ConversationMessage["attachments"]; - readonly driver?: ProviderDriverKind; - readonly nativeSurface?: { - readonly slashCommands: ReadonlyArray<{ readonly name: string }>; - readonly skills: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>; - }; readonly subagents?: ReadonlyArray<{ readonly origin: "app_owned" | "provider_native"; readonly childThreadId: ThreadId | null; @@ -376,16 +369,15 @@ function steerProviderText(input: { const providerThreadId = ProviderThreadId.make("provider-thread:steer-provenance"); const providerTurnId = ProviderTurnId.make("provider-turn:steer-provenance"); const messageId = MessageId.make("message:steer-provenance"); - const sessionDriver = input.driver ?? driver; const providerThread: OrchestrationV2ProviderThread = { id: providerThreadId, - driver: sessionDriver, + driver, providerInstanceId, providerSessionId, appThreadId: threadId, ownerNodeId: null, nativeThreadRef: { - driver: sessionDriver, + driver, nativeId: "native-thread:steer-provenance", strength: "strong", }, @@ -416,14 +408,11 @@ function steerProviderText(input: { const steered = yield* Ref.make(undefined); const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: providerInstanceId, - driver: sessionDriver, - ...(input.nativeSurface === undefined - ? {} - : { nativeInvocationSurface: () => Effect.succeed(input.nativeSurface!) }), + driver, providerSessionId, providerSession: { id: providerSessionId, - driver: sessionDriver, + driver, providerInstanceId, status: "running", cwd: "/workspace", @@ -472,27 +461,31 @@ function steerProviderText(input: { } as OrchestrationV2ThreadProjection["runs"][number], }), getThread: (requestedThreadId) => - input.senderLookupFails === true - ? Effect.fail( - new ProjectionStore.ProjectionStoreReadError({ - threadId: requestedThreadId, - cause: "sender lookup failed", - }), - ) - : Effect.succeed({ - title: input.senderTitle ?? "Sender", - } as never), + 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.subagentLookupFails === true - ? Effect.fail( - new ProjectionStore.ProjectionStoreReadError({ - threadId, - cause: "subagent lookup failed", - }), - ) - : Effect.succeed({ - subagents: input.subagents ?? [], - } as never), + 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), }), ), ), @@ -579,44 +572,22 @@ it.effect.each([ expected: steerBody, }, { - label: "a Codex /compact with a file is an ordinary steer", - createdBy: "agent" as const, - creationSource: "mcp" as const, - senderThreadId: steerChildThreadId, - senderTitle: "Review the PR", - text: "/compact", - attachments: [ - ChatFileAttachment.make({ - type: "file", - id: ChatAttachmentId.make("compact-file-12345678-1234-1234-1234-123456789abc"), - name: "notes.txt", - mimeType: "text/plain", - sizeBytes: 1, - }), - ], - expected: `Agent-authored message from "Review the PR", not the user.\n/compact`, - }, - { - label: "an unknown Grok skill is an ordinary steer", + label: "an agent Cursor /compress stays raw", createdBy: "agent" as const, creationSource: "mcp" as const, senderThreadId: steerChildThreadId, senderTitle: "Review the PR", - text: "$HOME is unset; use the fallback.", - driver: ProviderDriverKind.make("grok"), - nativeSurface: { slashCommands: [], skills: [] }, - expected: `Agent-authored message from "Review the PR", not the user.\n$HOME is unset; use the fallback.`, + text: "/compress", + expected: "/compress", }, { - label: "a disabled Grok skill is an ordinary steer", + 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: "$deploy ship it", - driver: ProviderDriverKind.make("grok"), - nativeSurface: { slashCommands: [], skills: [{ name: "deploy", enabled: false }] }, - expected: `Agent-authored message from "Review the PR", not the user.\n$deploy ship it`, + 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* () { diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 1b0e2e7266edf..f64491de9ed4d 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -346,25 +346,13 @@ export const layer: Layer.Layer< cause: "The persisted steering message or target run is missing.", }); } - const runtime = loaded.session.value; - const nativeSurface = runtime.nativeInvocationSurface - ? yield* runtime.nativeInvocationSurface({ cwd: runtime.providerSession.cwd }).pipe( - Effect.orElseSucceed(() => ({ - slashCommands: [], - skills: [], - })), - ) - : { slashCommands: [], skills: [] }; const text = yield* providerTextForTurn({ text: projectComposerContextForProvider({ text: message.text, records: message.context?.records ?? [], }), - attachments: message.attachments, createdBy: message.createdBy, senderThreadId: message.senderThreadId, - driver: runtime.driver, - nativeSurface, readSenderTitle: (senderThreadId) => projections.getThread(senderThreadId).pipe(Effect.map((thread) => thread.title)), readReceiverSubagents: () => diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 4bc69c27d8ba6..d739a9e0be740 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -3,8 +3,6 @@ import { expect, it, vi } from "vite-plus/test"; import { it as effectIt } from "@effect/vitest"; import { CheckpointScopeId, - ChatAttachmentId, - ChatFileAttachment, MessageId, NodeId, ProviderSessionId, @@ -288,11 +286,6 @@ function makeLocalCommandHarness(input: { readonly senderLookupFails?: boolean; readonly handlePromptCommand?: boolean; readonly attachments?: OrchestrationV2ThreadProjection["messages"][number]["attachments"]; - readonly driver?: ProviderDriverKind; - readonly nativeSurface?: { - readonly slashCommands: ReadonlyArray<{ readonly name: string }>; - readonly skills: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>; - }; }) { const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); const threadId = ThreadId.make("thread-native-account-command"); @@ -325,7 +318,7 @@ function makeLocalCommandHarness(input: { }; const providerThread: OrchestrationV2ThreadProjection["providerThreads"][number] = { id: providerThreadId, - driver: input.driver ?? ProviderDriverKind.make("codex"), + driver: ProviderDriverKind.make("codex"), providerInstanceId: newInstanceId, providerSessionId, appThreadId: threadId, @@ -623,11 +616,6 @@ function makeLocalCommandHarness(input: { }, ensureThread: () => Effect.succeed(providerThread), resumeThread: () => Effect.succeed(projection.providerThreads[0]), - ...(input.nativeSurface === undefined - ? {} - : { - nativeInvocationSurface: () => Effect.succeed(input.nativeSurface), - }), } as never) : Effect.die("A local command must not open a native session."), ); @@ -726,16 +714,18 @@ function makeLocalCommandHarness(input: { Layer.mock(ProjectionStore.ProjectionStoreV2)({ getThreadRecords, getThread: (threadId: ThreadId) => - input.senderLookupFails === true - ? Effect.fail( - new ProjectionStore.ProjectionStoreReadError({ - threadId, - cause: "sender lookup failed", - }), - ) - : Effect.succeed({ - title: input.senderTitles?.[threadId] ?? "Sender", - } as never), + 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, { @@ -1367,6 +1357,8 @@ effectIt.effect.each([ 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({ @@ -1387,55 +1379,3 @@ effectIt.effect.each([ expect(harness.tryHandlePromptCommand).toHaveBeenCalled(); }), ); - -const compactFile = ChatFileAttachment.make({ - type: "file", - id: ChatAttachmentId.make("compact-file-12345678-1234-1234-1234-123456789abc"), - name: "notes.txt", - mimeType: "text/plain", - sizeBytes: 1, -}); - -effectIt.effect.each([ - { - label: "a Codex /compact with a file is an ordinary prompt", - text: "/compact", - attachments: [compactFile], - driver: ProviderDriverKind.make("codex"), - }, - { - label: "an unknown Grok skill is ordinary text", - text: "$HOME is unset; use the fallback.", - driver: ProviderDriverKind.make("grok"), - nativeSurface: { slashCommands: [], skills: [] }, - }, - { - label: "a disabled Grok skill is ordinary text", - text: "$deploy ship it", - driver: ProviderDriverKind.make("grok"), - nativeSurface: { slashCommands: [], skills: [{ name: "deploy", enabled: false }] }, - }, -])("assembles provider text so $label", (input) => - Effect.gen(function* () { - const harness = makeLocalCommandHarness({ - text: input.text, - attachments: "attachments" in input ? input.attachments : [], - previousMessages: ["Earlier turn"], - previousTurn: settledPreviousTurn, - createdBy: "agent", - creationSource: "mcp", - senderThreadId: childThreadId, - senderTitles: { [childThreadId]: "Review the PR" }, - driver: input.driver, - ...("nativeSurface" in input ? { nativeSurface: input.nativeSurface } : {}), - handlePromptCommand: false, - }); - - yield* harness.start; - - expect(harness.projection().messages.at(-1)?.text).toBe(input.text); - expect(harness.startRootRun.mock.calls[0]?.[0].message.text).toBe( - `Agent-authored message from "Review the PR", not the user.\n${input.text}`, - ); - }), -); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index ab8ea0a26b36d..5bd3866a9d41f 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1026,25 +1026,13 @@ export const layer: Layer.Layer< const routableSubagents = projection.subagents.filter((subagent) => RunExecutionService.canRouteRelatedSubagent(subagent.status), ); - const nativeSurface = session.nativeInvocationSurface - ? yield* session.nativeInvocationSurface({ cwd: resolvedRuntimePolicy.cwd }).pipe( - Effect.orElseSucceed(() => ({ - slashCommands: [], - skills: [], - })), - ) - : { slashCommands: [], skills: [] }; const userText = yield* providerTextForTurn({ text: projectComposerContextForProvider({ text: message.text, records: message.context?.records ?? [], }), - attachments: message.attachments, createdBy: message.createdBy, senderThreadId: message.senderThreadId, - restartContinuation: run.restartContinuationOfRunId !== undefined, - driver: session.driver, - nativeSurface, readSenderTitle: (senderThreadId) => projectionStore.getThread(senderThreadId).pipe(Effect.map((thread) => thread.title)), readReceiverSubagents: () => Effect.succeed(projection.subagents), diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index ba3bfb453505d..72debb9706403 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1025,10 +1025,8 @@ it.effect("an agent /compact still compacts", () => }); const agentCompact = yield* providerTextForTurn({ text: "/compact", - attachments: [], createdBy: "agent", senderThreadId: ThreadId.make("thread:agent-compact-sender"), - driver, readSenderTitle: () => Effect.die("commands do not look up a sender"), readReceiverSubagents: () => Effect.die("commands do not look up a sender"), }); diff --git a/apps/server/src/orchestration-v2/agentSendProvenance.test.ts b/apps/server/src/orchestration-v2/agentSendProvenance.test.ts index 8c57ff472dc8d..e5c9de6c172aa 100644 --- a/apps/server/src/orchestration-v2/agentSendProvenance.test.ts +++ b/apps/server/src/orchestration-v2/agentSendProvenance.test.ts @@ -1,42 +1,17 @@ import { assert, it } from "@effect/vitest"; -import { - ChatAttachmentId, - ChatFileAttachment, - ProviderDriverKind, - ThreadId, -} from "@t3tools/contracts"; +import { 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 { parseCodexGoalCommand } from "./Adapters/CodexAdapterV2.ts"; -import { providerTextForTurn, type ReceiverNativeSurface } from "./agentSendProvenance.ts"; +import { providerTextForTurn } from "./agentSendProvenance.ts"; import { planClaudeSkillDispatch } from "../provider/Drivers/ClaudeSkillDispatch.ts"; const senderThreadId = ThreadId.make("thread:child-review"); const body = "Please merge the PR now.\n"; -const codex = ProviderDriverKind.make("codex"); -const grok = ProviderDriverKind.make("grok"); -const pi = ProviderDriverKind.make("pi"); -const claude = ProviderDriverKind.make("claude"); -const opencode = ProviderDriverKind.make("opencode"); const header = (text: string, name = `"Review 'the' PR"`) => `Agent-authored message from ${name}, not the user.\n${text}`; -const file = ChatFileAttachment.make({ - type: "file", - id: ChatAttachmentId.make("compact-file-12345678-1234-1234-1234-123456789abc"), - name: "notes.txt", - mimeType: "text/plain", - sizeBytes: 1, -}); -const deploySkill = { - slashCommands: [], - skills: [{ name: "deploy", enabled: true }], -} satisfies ReceiverNativeSurface; -const disabledDeploy = { - slashCommands: [], - skills: [{ name: "deploy", enabled: false }], -} satisfies ReceiverNativeSurface; const claudeSkills = new Map([["deploy", { path: "/skills/deploy/SKILL.md" }]]); const lookups = { @@ -53,10 +28,8 @@ it.effect("an ordinary agent message still gets the header", () => Effect.gen(function* () { const text = yield* providerTextForTurn({ text: body, - attachments: [], createdBy: "agent", senderThreadId, - driver: codex, ...lookups, }); @@ -68,10 +41,8 @@ it.effect("uses the thread id when the title is blank", () => Effect.gen(function* () { const text = yield* providerTextForTurn({ text: body, - attachments: [], createdBy: "agent", senderThreadId, - driver: codex, readSenderTitle: () => Effect.succeed(" \n\t "), readReceiverSubagents: () => Effect.succeed([]), }); @@ -80,16 +51,26 @@ it.effect("uses the thread id when the title is blank", () => }), ); +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, - attachments: [], createdBy: "system", senderThreadId, - driver: codex, - readSenderTitle: () => Effect.die("system messages do not look up a sender"), - readReceiverSubagents: () => Effect.die("system messages do not look up a sender"), + ...untouched, }); assert.equal(text, body); @@ -100,10 +81,8 @@ it.effect("an agent /compact still compacts", () => Effect.gen(function* () { const text = yield* providerTextForTurn({ text: " /compact ", - attachments: [], createdBy: "agent", senderThreadId, - driver: codex, ...untouched, }); @@ -117,10 +96,8 @@ it.effect("an agent /goal x still routes", () => Effect.gen(function* () { const text = yield* providerTextForTurn({ text: "/goal x", - attachments: [], createdBy: "agent", senderThreadId, - driver: codex, ...untouched, }); @@ -132,11 +109,8 @@ it.effect("an agent $deploy ship it still matches the Grok skill parser", () => Effect.gen(function* () { const text = yield* providerTextForTurn({ text: "$deploy ship it", - attachments: [], createdBy: "agent", senderThreadId, - driver: grok, - nativeSurface: deploySkill, ...untouched, }); @@ -144,146 +118,59 @@ it.effect("an agent $deploy ship it still matches the Grok skill parser", () => }), ); -it.effect("still headers prose that only looks like a command or a price", () => +it.effect("keeps a leading slash raw, including Cursor /compress and a Claude legacy command", () => Effect.gen(function* () { - for (const text of ["/please review this", "$25 off"]) { + for (const text of [ + "/compress", + "/legacy files", + "/review staged changes", + "/please review this", + "/my-command", + "/skill:deploy", + ]) { const providerText = yield* providerTextForTurn({ text, - attachments: [], createdBy: "agent", senderThreadId, - driver: codex, - ...lookups, - }); - assert.equal(providerText, header(text)); - } - }), -); - -it.effect("headers unknown and disabled Grok skills, including on other drivers", () => - Effect.gen(function* () { - const cases = [ - { driver: grok, text: "$HOME is unset; use the fallback.", nativeSurface: undefined }, - { driver: grok, text: "$deploy ship it", nativeSurface: disabledDeploy }, - { driver: codex, text: "$HOME is unset; use the fallback.", nativeSurface: deploySkill }, - { driver: codex, text: "$deploy ship it", nativeSurface: deploySkill }, - ] as const; - for (const input of cases) { - const providerText = yield* providerTextForTurn({ - text: input.text, - attachments: [], - createdBy: "agent", - senderThreadId, - driver: input.driver, - ...(input.nativeSurface === undefined ? {} : { nativeSurface: input.nativeSurface }), - ...lookups, + ...untouched, }); - assert.equal(providerText, header(input.text)); + assert.equal(providerText, text); } }), ); -it.effect("headers Codex /compact when a file is attached, and Pi compact only on Pi", () => - Effect.gen(function* () { - const withFile = yield* providerTextForTurn({ - text: "/compact", - attachments: [file], - createdBy: "agent", - senderThreadId, - driver: codex, - ...lookups, - }); - assert.equal(withFile, header("/compact")); - - const argued = yield* providerTextForTurn({ - text: "/compact keep API details", - attachments: [], - createdBy: "agent", - senderThreadId, - driver: codex, - ...lookups, - }); - assert.equal(argued, header("/compact keep API details")); - - const piCompact = yield* providerTextForTurn({ - text: "/compact keep API details", - attachments: [file], - createdBy: "agent", - senderThreadId, - driver: pi, - ...untouched, - }); - assert.equal(piCompact, "/compact keep API details"); - }), -); - -it.effect("keeps a discovered command only for the driver that has it", () => +it.effect("keeps a leading skill token raw and still headers a price or a later mention", () => Effect.gen(function* () { - const review = { - slashCommands: [{ name: "review" }], - skills: [], - } satisfies ReceiverNativeSurface; - const kept = yield* providerTextForTurn({ - text: "/review staged changes", - attachments: [], + const home = yield* providerTextForTurn({ + text: "$HOME is unset; use the fallback.", createdBy: "agent", senderThreadId, - driver: opencode, - nativeSurface: review, ...untouched, }); - assert.equal(kept, "/review staged changes"); + assert.equal(home, "$HOME is unset; use the fallback."); - const unknown = yield* providerTextForTurn({ - text: "/review staged changes", - attachments: [], + const price = yield* providerTextForTurn({ + text: "$25 off", createdBy: "agent", senderThreadId, - driver: opencode, ...lookups, }); - assert.equal(unknown, header("/review staged changes")); + assert.equal(price, header("$25 off")); - const prose = yield* providerTextForTurn({ - text: "/please review this", - attachments: [], - createdBy: "agent", - senderThreadId, - driver: opencode, - nativeSurface: review, - ...lookups, - }); - assert.equal(prose, header("/please review this")); - }), -); - -it.effect("keeps Pi custom commands and skill invocations", () => - Effect.gen(function* () { - const surface = { - slashCommands: [{ name: "my-command" }], - skills: [{ name: "deploy", enabled: true }], - } satisfies ReceiverNativeSurface; - for (const text of ["/my-command", "/skill:deploy"]) { - const providerText = yield* providerTextForTurn({ - text, - attachments: [], - createdBy: "agent", - senderThreadId, - driver: pi, - nativeSurface: surface, - ...untouched, - }); - assert.equal(providerText, text); - } - const missing = yield* providerTextForTurn({ - text: "/my-command", - attachments: [], + const later = yield* providerTextForTurn({ + text: "Ship $deploy now", createdBy: "agent", senderThreadId, - driver: pi, - ...lookups, + readSenderTitle: () => Effect.succeed("Review $deploy job"), + readReceiverSubagents: () => Effect.succeed([]), }); - assert.equal(missing, header("/my-command")); + 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"); }), ); @@ -292,41 +179,12 @@ it.effect("a skill token in the sender title does not invoke that skill", () => for (const title of ["Review $deploy job", "Review €deploy job", "Review £deploy job"]) { const text = yield* providerTextForTurn({ text: "The review is complete.", - attachments: [], createdBy: "agent", senderThreadId, - driver: claude, readSenderTitle: () => Effect.succeed(title), readReceiverSubagents: () => Effect.succeed([]), }); assert.isUndefined(planClaudeSkillDispatch(text, claudeSkills)); } - - const intentional = yield* providerTextForTurn({ - text: "Ship $deploy now", - attachments: [], - createdBy: "agent", - senderThreadId, - driver: claude, - readSenderTitle: () => Effect.succeed("Review $deploy job"), - readReceiverSubagents: () => Effect.succeed([]), - }); - const plan = planClaudeSkillDispatch(intentional, claudeSkills); - assert.equal(plan?.skillName, "deploy"); - assert.equal(plan?.commandText, "/deploy now"); - }), -); - -it.effect("a Codex goal is ordinary text for every other driver", () => - Effect.gen(function* () { - const text = yield* providerTextForTurn({ - text: "/goal clear", - attachments: [], - createdBy: "agent", - senderThreadId, - driver: opencode, - ...lookups, - }); - assert.equal(text, header("/goal clear")); }), ); diff --git a/apps/server/src/orchestration-v2/agentSendProvenance.ts b/apps/server/src/orchestration-v2/agentSendProvenance.ts index c85e9cccde945..d417d608c2ae8 100644 --- a/apps/server/src/orchestration-v2/agentSendProvenance.ts +++ b/apps/server/src/orchestration-v2/agentSendProvenance.ts @@ -1,32 +1,9 @@ -import type { - ChatAttachment, - OrchestrationV2Actor, - OrchestrationV2Subagent, - ProviderDriverKind, - ThreadId, -} from "@t3tools/contracts"; +import type { OrchestrationV2Actor, OrchestrationV2Subagent, ThreadId } from "@t3tools/contracts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; -import { leadingGrokSkillMention } from "@t3tools/provider-grok/server/skills"; -import { parsePiCompactCommand } from "@t3tools/provider-pi/server/commands"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { parseCodexGoalCommand } from "./Adapters/CodexAdapterV2.ts"; -import { isNativeMaintenanceCommand } from "./CommandPolicy.ts"; - -/** - * Commands and skills the receiving driver would actually invoke. - * An empty surface preserves only that driver's built-in forms. - */ -export interface ReceiverNativeSurface { - readonly slashCommands: ReadonlyArray<{ readonly name: string }>; - readonly skills: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>; -} - -const emptyNativeSurface = { - slashCommands: [], - skills: [], -} as const satisfies ReceiverNativeSurface; +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. @@ -38,23 +15,18 @@ export const providerTextForTurn = Effect.fn("providerTextForTurn")(function* < SubagentError, >(input: { readonly text: string; - readonly attachments: ReadonlyArray; readonly createdBy: OrchestrationV2Actor; readonly senderThreadId?: ThreadId | undefined; - readonly restartContinuation?: boolean; - readonly driver: ProviderDriverKind; - readonly nativeSurface?: ReceiverNativeSurface; readonly readSenderTitle: (senderThreadId: ThreadId) => Effect.Effect; readonly readReceiverSubagents: () => Effect.Effect< ReadonlyArray>, SubagentError >; }) { - if ( - input.createdBy !== "agent" || - input.senderThreadId === undefined || - preservesNativeInvocation(input) - ) { + if (input.createdBy !== "agent" || input.senderThreadId === undefined) { + return input.text; + } + if (preservesNativeSyntax(input.text)) { return input.text; } const senderThreadId = input.senderThreadId; @@ -69,53 +41,11 @@ export const providerTextForTurn = Effect.fn("providerTextForTurn")(function* < return `Agent-authored message from ${source}, not the user.\n${input.text}`; }); -const SLASH_COMMAND = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/u; - -function preservesNativeInvocation(input: { - readonly text: string; - readonly attachments: ReadonlyArray; - readonly restartContinuation?: boolean; - readonly driver: ProviderDriverKind; - readonly nativeSurface?: ReceiverNativeSurface; -}): boolean { - const surface = input.nativeSurface ?? emptyNativeSurface; - const driver = input.driver; - if ( - driver === "codex" && - input.attachments.length === 0 && - input.restartContinuation !== true && - parseCodexGoalCommand(input.text) !== null - ) { - return true; - } - if (driver === "pi" && parsePiCompactCommand(input.text) !== null) return true; - if (driver === "grok") { - const mention = leadingGrokSkillMention(input.text); - if ( - mention !== undefined && - surface.skills.some((skill) => skill.enabled && skill.name === mention.name) - ) { - return true; - } - } - if (isNativeMaintenanceCommand(input)) return true; - const command = SLASH_COMMAND.exec(input.text.trim()); - const name = command?.[1]; - if (name === undefined) return false; - // Exact /compact and /logout are the maintenance check. Argument forms are Pi-only. - if (name === "compact" || name === "logout") return false; - if (surface.slashCommands.some((entry) => entry.name === name)) return true; - if ( - driver === "pi" && - name.startsWith("skill:") && - surface.skills.some((skill) => skill.enabled && skill.name === name.slice("skill:".length)) - ) { - return true; - } - return ( - (driver === "claude" || driver === "grok" || driver === "cursor") && - surface.skills.some((skill) => skill.enabled && skill.name === name) - ); +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] === ""; } // Skill parsers accept any currency sigil. A word joiner keeps the title from matching. diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 490e781615866..3ac79ddc614f6 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -226,13 +226,6 @@ export interface AcpAdapterV2Flavor { text: string, turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, ) => Effect.Effect; - readonly nativeSkills?: ( - cwd?: string | null, - ) => Effect.Effect< - ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>, - never, - ChildProcessSpawner.ChildProcessSpawner - >; readonly driver: ProviderDriverKind; readonly capabilities: OrchestrationV2ProviderCapabilities; readonly clientCapabilitiesMeta?: NonNullable< @@ -1582,7 +1575,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const activeSelection = yield* Ref.make(null); const activeInteractionMode = yield* Ref.make(null); const promptInstructionStates = yield* Ref.make(new Map()); - const availableCommandNames = yield* Ref.make>([]); // Unique markers keep delayed prompt completions from clearing later compactions. const compactionMarkers = yield* Ref.make(new Map()); const runtimeRestartRequired = yield* Ref.make(false); @@ -5766,10 +5758,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( clientTerminals.readOutputSnapshot, ); if (notification.update.sessionUpdate === "available_commands_update") { - yield* Ref.set( - availableCommandNames, - notification.update.availableCommands.map((command) => command.name), - ); yield* ( flavor.onAvailableCommandsUpdate?.(notification.update.availableCommands) ?? Effect.void @@ -7679,26 +7667,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const sessionRuntime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: options.instanceId, driver, - nativeInvocationSurface: Effect.fn("AcpAdapterV2.nativeInvocationSurface")( - function* (surfaceInput) { - const names = yield* Ref.get(availableCommandNames); - const skills = - flavor.nativeSkills === undefined - ? [] - : yield* flavor - .nativeSkills(surfaceInput.cwd) - .pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - childProcessSpawner, - ), - ); - return { - slashCommands: names.map((name) => ({ name })), - skills, - }; - }, - ), providerSessionId: input.providerSessionId, providerSession, events: Stream.fromEffectRepeat(Queue.take(events)), diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 3c114a92d8416..7c77b7bb0ac6f 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -578,11 +578,6 @@ export interface ProviderAdapterV2SessionRuntime { readonly startTurn: ( input: ProviderAdapterV2TurnInput, ) => Effect.Effect; - /** Slash commands and skills this session would invoke, from the driver's own catalog. */ - readonly nativeInvocationSurface?: (input: { readonly cwd?: string | null }) => Effect.Effect<{ - readonly slashCommands: ReadonlyArray<{ readonly name: string }>; - readonly skills: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>; - }>; readonly compactThread?: ( input: ProviderAdapterV2TurnInput, ) => Effect.Effect; diff --git a/packages/provider-grok/src/server/adapter.ts b/packages/provider-grok/src/server/adapter.ts index 27f9b3687f96f..8fb19014a25cd 100644 --- a/packages/provider-grok/src/server/adapter.ts +++ b/packages/provider-grok/src/server/adapter.ts @@ -239,13 +239,6 @@ export function makeGrokAcpAdapterFlavor( supportsCompaction: true, // Grok expands only a leading `/name`, while the `$` picker inserts `$name` from the // `grok inspect` catalog. Ask that catalog again so other `$` text is left alone. - nativeSkills: (cwd) => - discoverGrokSkills(options.settings, options.environment, cwd ?? undefined).pipe( - Effect.map((skills) => - skills.map((skill) => ({ name: skill.name, enabled: skill.enabled })), - ), - Effect.orElseSucceed(() => []), - ), rewriteUserText: (text, turnInput) => { const mention = leadingGrokSkillMention(text); if (mention === undefined) return Effect.succeed(text); diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 9a018ad838df7..68de1977d8c66 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -1166,14 +1166,10 @@ describe("OpenCodeAdapterV2", () => { sessionId, client, ); - const surface = yield* commandHarness.runtime.nativeInvocationSurface!({}); const commandText = yield* providerTextForTurn({ text: "/review staged changes", - attachments: [], createdBy: "agent", senderThreadId: ThreadId.make("thread:child-review"), - driver: OPENCODE_PROVIDER, - nativeSurface: surface, readSenderTitle: () => Effect.die("commands do not look up a sender"), readReceiverSubagents: () => Effect.die("commands do not look up a sender"), }); @@ -1222,19 +1218,14 @@ describe("OpenCodeAdapterV2", () => { ); const prose = yield* providerTextForTurn({ text: "/please review this", - attachments: [], createdBy: "agent", senderThreadId: ThreadId.make("thread:child-review"), - driver: OPENCODE_PROVIDER, - nativeSurface: surface, - readSenderTitle: () => Effect.succeed("Review the PR"), - readReceiverSubagents: () => Effect.succeed([]), + 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( - prompted, - `Agent-authored message from "Review the PR", not the user.\n/please review this`, - ); + assert.equal(prose, "/please review this"); + assert.equal(prompted, "/please review this"); }).pipe( Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), Effect.scoped, diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 2d157cd9bb55b..d872b723df793 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -2974,15 +2974,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function providerSession: sessionEntity, events: Stream.fromEffectRepeat(Queue.take(events)), hasPendingBackgroundWork: Effect.sync(() => busySessionIds.size > 0), - nativeInvocationSurface: () => - OpenCodeRuntime.loadOpenCodeCommands(client).pipe( - Effect.timeout("10 seconds"), - Effect.map((commands) => ({ - slashCommands: commands.map((command) => ({ name: command.name })), - skills: [], - })), - Effect.orElseSucceed(() => ({ slashCommands: [], skills: [] })), - ), ensureThread: Effect.fn("OpencodeAdapter.ensureThread")( function* (threadInput) { if (threadInput.existingProviderThread?.nativeThreadRef != null) { diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index ce7ea2a74e3f9..dba02e02fa0b1 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -3336,19 +3336,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr events: Stream.fromQueue(events), // Idle release must wait for background children and held wakes after parent settlement. hasPendingBackgroundWork: Effect.sync(() => [...threads.values()].some(owesWork)), - nativeInvocationSurface: (surfaceInput) => - client.command - .list({ - location: { directory: surfaceInput.cwd ?? host.paths.cwd }, - }) - .pipe( - Effect.timeout(INVENTORY_TIMEOUT), - Effect.map((list) => ({ - slashCommands: list.data.map((command) => ({ name: command.name })), - skills: [], - })), - Effect.orElseSucceed(() => ({ slashCommands: [], skills: [] })), - ), hasPendingBackgroundWorkForThread: (providerThread) => Effect.sync(() => { const nativeId = providerThread.nativeThreadRef?.nativeId; diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 1eb92f1976afd..afc3c8b403a35 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -165,7 +165,6 @@ interface FakePi { readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; readonly deferNextModelSelection: () => void; readonly queueModels: (models: ReadonlyArray) => void; - readonly queueCommands: (data: unknown) => void; readonly vetoNextNewSession: () => void; readonly allRequests: () => ReadonlyArray; readonly queueStats: (data: unknown) => void; @@ -204,7 +203,6 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let vetoSwitch = false; let vetoNewSession = false; let deferredLifecycle: string | undefined; - let commandsData: unknown = { commands: [] }; let sessionFile = FAKE_SESSION_FILE; let sessionGeneration = 0; let models: ReadonlyArray = []; @@ -255,8 +253,6 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { return base; case "get_available_models": return { ...base, data: { models } }; - case "get_commands": - return { ...base, data: commandsData }; case "new_session": { const cancelled = vetoNewSession; vetoNewSession = false; @@ -377,9 +373,6 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { queueModels: (value) => { models = value; }, - queueCommands: (data) => { - commandsData = data; - }, vetoNextNewSession: () => { vetoNewSession = true; }, @@ -529,23 +522,16 @@ describe("PiAdapterV2", () => { it.effect("keeps an agent custom command at the start of the prompt", () => Effect.gen(function* () { const fake = yield* makeFakePi; - fake.queueCommands({ - commands: [{ name: "my-command", description: "Custom", source: "extension" }], - }); const { runtime } = yield* openRuntime(fake); const providerThread = yield* runtime.ensureThread({ threadId: THREAD_ID, modelSelection: modelSelection("default"), runtimePolicy, }); - const surface = yield* runtime.nativeInvocationSurface!({}); const text = yield* providerTextForTurn({ text: "/my-command", - attachments: [], createdBy: "agent", senderThreadId: ThreadId.make("thread:child-review"), - driver: PI_PROVIDER, - nativeSurface: surface, readSenderTitle: () => Effect.die("commands do not look up a sender"), readReceiverSubagents: () => Effect.die("commands do not look up a sender"), }); diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 68f31009d6841..38b6681ec2f23 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -42,12 +42,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; -import { - parsePiCompactCommand, - parsePiDiscoveredCommands, - withPiBuiltinSlashCommands, - type PiCompactCommand, -} from "./commands.ts"; +import { parsePiCompactCommand, type PiCompactCommand } from "./commands.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -2325,12 +2320,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return { message, images }; }); - let piCommandSurface: - | { - readonly slashCommands: ReadonlyArray<{ readonly name: string }>; - readonly skills: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>; - } - | undefined; const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: options.instanceId, driver: PI_PROVIDER, @@ -2340,26 +2329,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }, events: Stream.fromQueue(events), hasPendingBackgroundWork: Effect.sync(() => pendingWake !== null), - nativeInvocationSurface: () => { - if (piCommandSurface !== undefined) return Effect.succeed(piCommandSurface); - return request({ type: "get_commands" }).pipe( - Effect.map((data) => { - const discovered = parsePiDiscoveredCommands(data); - const surface = { - slashCommands: withPiBuiltinSlashCommands(discovered.slashCommands).map( - (command) => ({ name: command.name }), - ), - skills: discovered.skills.map((skill) => ({ - name: skill.name, - enabled: skill.enabled, - })), - }; - piCommandSurface = surface; - return surface; - }), - Effect.orElseSucceed(() => ({ slashCommands: [], skills: [] })), - ); - }, hasPendingBackgroundWorkForThread: (providerThread) => Effect.sync( () => pendingWake !== null && pendingWake.state.providerThread.id === providerThread.id, From 84f09c1515ec6edfa394f663b4acc353c1cd92de Mon Sep 17 00:00:00 2001 From: Wes Hines Date: Sun, 25 Oct 2026 00:53:43 -0500 Subject: [PATCH 5/7] fix(server): neutralize every currency sigil in a sender title Pi loads any whitespace-delimited dollar token as a skill, including numeric names. A word joiner after each currency sigil keeps the title from matching while the body still can. Model: Grok 4.7 Co-authored-by: Cursor --- .../orchestration-v2/agentSendProvenance.ts | 10 ++---- .../src/server/mcpExtensionSource.test.ts | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/agentSendProvenance.ts b/apps/server/src/orchestration-v2/agentSendProvenance.ts index d417d608c2ae8..418f89dab7410 100644 --- a/apps/server/src/orchestration-v2/agentSendProvenance.ts +++ b/apps/server/src/orchestration-v2/agentSendProvenance.ts @@ -48,15 +48,9 @@ function preservesNativeSyntax(text: string): boolean { return mention !== null && mention.index === 0 && mention[1] === ""; } -// Skill parsers accept any currency sigil. A word joiner keeps the title from matching. +// 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(new RegExp(SKILL_MENTION_PATTERN.source, "gu"), (match, lead: string) => { - const rest = match.slice(lead.length); - const currency = rest.codePointAt(0); - if (currency === undefined) return match; - const currencyText = String.fromCodePoint(currency); - return `${lead}${currencyText}\u2060${rest.slice(currencyText.length)}`; - }); + return text.replace(/\p{Sc}/gu, "$&\u2060"); } function senderName(title: string | undefined, senderThreadId: ThreadId): string { diff --git a/packages/provider-pi/src/server/mcpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts index b1306f701e27a..22cfcea3f01b5 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 "../../../../apps/server/src/orchestration-v2/agentSendProvenance.ts"; 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.each(["deploy", "25", "123", "deploy.v2"])( + "does not load skill %s from the sender title", + async (name) => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-title-skill-")); + try { + const path = NodePath.join(directory, "SKILL.md"); + await NodeFSP.writeFile(path, "TITLE_SKILL_INSTRUCTIONS"); + const hook = await loadInputHook([ + { name: `skill:${name}`, source: "skill", sourceInfo: { path } }, + ]); + const body = "The review is complete."; + assert.isUndefined(await hook({ text: body }, { ui: { notify: assert.fail } })); + const text = await Effect.runPromise( + providerTextForTurn({ + text: body, + createdBy: "agent", + senderThreadId: ThreadId.make("thread:title-skill"), + readSenderTitle: () => Effect.succeed(`Review $${name} job`), + readReceiverSubagents: () => Effect.succeed([]), + }), + ); + assert.isUndefined(await hook({ text }, { ui: { notify: assert.fail } })); + const requested = await hook({ text: `Use $${name} now` }, { ui: { notify: assert.fail } }); + assert.include(requested?.text ?? "", `Use $${name} now`); + assert.include(requested?.text ?? "", "TITLE_SKILL_INSTRUCTIONS"); + } finally { + await NodeFSP.rm(directory, { recursive: true }); + } + }, + ); }); From 1051656c694d0c0a34041108483353c64e389074 Mon Sep 17 00:00:00 2001 From: Wes Hines Date: Sun, 25 Oct 2026 00:55:54 -0500 Subject: [PATCH 6/7] test(pi): run the title-skill regression inside Effect Model: Grok 4.7 Co-authored-by: Cursor --- .../src/server/mcpExtensionSource.test.ts | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/provider-pi/src/server/mcpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts index 22cfcea3f01b5..bb7bf6c9c497f 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.test.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.test.ts @@ -523,34 +523,34 @@ describe("Pi skill references", () => { } }); - it.each(["deploy", "25", "123", "deploy.v2"])( + it.effect.each(["deploy", "25", "123", "deploy.v2"])( "does not load skill %s from the sender title", - async (name) => { - const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-title-skill-")); - try { - const path = NodePath.join(directory, "SKILL.md"); - await NodeFSP.writeFile(path, "TITLE_SKILL_INSTRUCTIONS"); - const hook = await loadInputHook([ - { name: `skill:${name}`, source: "skill", sourceInfo: { path } }, - ]); - const body = "The review is complete."; - assert.isUndefined(await hook({ text: body }, { ui: { notify: assert.fail } })); - const text = await Effect.runPromise( - providerTextForTurn({ + (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(await hook({ text }, { ui: { notify: assert.fail } })); - const requested = await hook({ text: `Use $${name} now` }, { ui: { notify: assert.fail } }); - assert.include(requested?.text ?? "", `Use $${name} now`); - assert.include(requested?.text ?? "", "TITLE_SKILL_INSTRUCTIONS"); - } finally { - await NodeFSP.rm(directory, { recursive: true }); - } - }, + }); + 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 })))); + }), ); }); From 2e9be24e754aaa05bc2f7ec1fc83de2d6b2e558f Mon Sep 17 00:00:00 2001 From: Wes Hines Date: Mon, 26 Oct 2026 20:10:12 -0500 Subject: [PATCH 7/7] refactor(shared): share agent send provenance with providers Move the existing formatter into shared so provider tests use a package export instead of importing server application code. Keep provider coverage and prompt behavior unchanged. Model: GPT-6.1-Sol --- .../src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts | 2 +- .../server/src/orchestration-v2/ProviderTurnControlService.ts | 2 +- apps/server/src/orchestration-v2/ProviderTurnStartService.ts | 2 +- apps/server/src/orchestration-v2/RunExecutionService.test.ts | 2 +- apps/server/src/orchestration-v2/agentSendProvenance.test.ts | 2 +- packages/provider-opencode/src/server/adapter.test.ts | 2 +- packages/provider-pi/src/server/adapter.test.ts | 2 +- packages/provider-pi/src/server/mcpExtensionSource.test.ts | 2 +- packages/shared/package.json | 4 ++++ .../shared/src}/agentSendProvenance.ts | 3 ++- 10 files changed, 14 insertions(+), 9 deletions(-) rename {apps/server/src/orchestration-v2 => packages/shared/src}/agentSendProvenance.ts (97%) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 800bde84b20cd..4ee3920c02586 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -38,7 +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 "../agentSendProvenance.ts"; +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"; diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index f64491de9ed4d..410bb9d905e06 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -17,7 +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 "./agentSendProvenance.ts"; +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 9f1e5e90477b3..e01604c5e1bf6 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -31,7 +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 "./agentSendProvenance.ts"; +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; import * as EventSink from "./EventSink.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import { diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index d13618ed55a84..e482d8ae24234 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -47,7 +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 "./agentSendProvenance.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"; diff --git a/apps/server/src/orchestration-v2/agentSendProvenance.test.ts b/apps/server/src/orchestration-v2/agentSendProvenance.test.ts index e973dbb85cf2d..0d7da46d3b1bc 100644 --- a/apps/server/src/orchestration-v2/agentSendProvenance.test.ts +++ b/apps/server/src/orchestration-v2/agentSendProvenance.test.ts @@ -4,7 +4,7 @@ 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 "./agentSendProvenance.ts"; +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; import { planClaudeSkillDispatch } from "../provider/Drivers/ClaudeSkillDispatch.ts"; const senderThreadId = ThreadId.make("thread:child-review"); diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 9e36e016fdaff..8792e4190f308 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -36,7 +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 "../../../../apps/server/src/orchestration-v2/agentSendProvenance.ts"; +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; import { advanceOpenCodePromptAdmission, cancelOpenCodePromptAdmission, diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index da4111b0496a3..1bd61defcb860 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -1,4 +1,4 @@ -import { providerTextForTurn } from "../../../../apps/server/src/orchestration-v2/agentSendProvenance.ts"; +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { diff --git a/packages/provider-pi/src/server/mcpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts index bb7bf6c9c497f..21a8796177bed 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.test.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.test.ts @@ -8,7 +8,7 @@ import { assert, describe, it } from "@effect/vitest"; import { ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { providerTextForTurn } from "../../../../apps/server/src/orchestration-v2/agentSendProvenance.ts"; +import { providerTextForTurn } from "@t3tools/shared/agentSendProvenance"; import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts"; import { loadMcpBridge } from "./mcpBridge.testkit.ts"; 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/apps/server/src/orchestration-v2/agentSendProvenance.ts b/packages/shared/src/agentSendProvenance.ts similarity index 97% rename from apps/server/src/orchestration-v2/agentSendProvenance.ts rename to packages/shared/src/agentSendProvenance.ts index 418f89dab7410..eb855c493e278 100644 --- a/apps/server/src/orchestration-v2/agentSendProvenance.ts +++ b/packages/shared/src/agentSendProvenance.ts @@ -1,8 +1,9 @@ import type { OrchestrationV2Actor, OrchestrationV2Subagent, ThreadId } from "@t3tools/contracts"; -import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; 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"); /**