diff --git a/documentor/FORK.md b/documentor/FORK.md index 115ae1428530..21fa288f99fc 100644 --- a/documentor/FORK.md +++ b/documentor/FORK.md @@ -13,33 +13,34 @@ what a rebase meets. ## Upstream files changed -| File | What differs | Why | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| The 11 table files: `packages/core/src/{account,credential,event,permission,project,session,share}/sql.ts`, `control-plane/workspace.sql.ts`, `data-migration.sql.ts`, `database/schema.sql.ts`, `database/path.ts` | Postgres table builder; `count`, `jsonb` and `text` columns from `effect-drizzle-pg`; time columns are `double precision` | Written by `documentor/scripts/convert-schema.py`, never by hand | -| `packages/core/src/database/schema.gen.ts`, `packages/core/schema.json`, `packages/core/drizzle.config.ts` | The Postgres baseline, as one statement batch | Generated; see "Regenerating the baseline" | -| `packages/core/src/database/database.ts` | Connects with `@effect/sql-pg`; verifies collation and migrations and changes nothing; a throwaway schema per layer when `OPENCODE_DATABASE_EPHEMERAL=1` | The service itself | -| `packages/core/src/database/migration.ts` | `migrate`, `migrateAll`, `verify`, `pending`, `BASELINE_IDS`, `ported` | Schema work is a command, not a start-up side effect | -| `packages/core/src/project/directories.ts` | `Transaction` is `Database.Transaction` | It was typed against the SQLite client | -| `packages/core/src/flag/flag.ts` | No `OPENCODE_DB` | Nothing reads it | -| `packages/core/package.json` | Depends on `@effect/sql-pg`, `pg`, `@opencode-ai/effect-drizzle-pg`; tests run with `--timeout 30000` | Each test database is a real schema, about 110 ms to build | -| `packages/opencode/src/cli/cmd/db.ts` | `db `, `db migrate [--all]`, `db provision`, `db grant-read`, all outside the application runtime | The runtime's database service refuses the schemas these commands exist to create and migrate | -| `packages/opencode/src/session/processor.ts` | One lock per processor around each read-then-write of a tool part; `tool-input-delta` and `tool-input-end` do not read a part the processor already tracks | The processor and the running tool both update a tool part. SQLite made each update atomic; a network database lets them interleave, and the tool's metadata was lost. Each read is a round trip, and a large edit streams thousands of deltas | -| `packages/opencode/src/acp/service.ts` | `prompt` passes a `messageId` that starts with `msg` to v1 as `messageID`, for a normal prompt and a slash command; an empty prompt passes none. `newSession` passes a `_meta.opencode.sessionId` that starts with `ses` to `session.create` as `id` | DocuMentor names the user message a prompt creates and the session a new chat creates, so their optimistic, live and durable copies share one id. ACP clients that send UUIDs, or no `_meta`, keep opencode-minted ids | -| `packages/opencode/src/acp/service.ts` | `prompt` reads `_meta.documentor = { author, answerable, noReply, system, partMetadata }` and forwards each well-formed field to v1, `partMetadata` as the first text part's `metadata`; a prompt with `noReply` or `answerable: false` is sent without the idle wait and answers `end_turn` at once; a request carrying `_meta.documentor` is never read as a slash command; an answer that is a user message is `end_turn` without usage; `newSession` reads `_meta.documentor.metadata` into `session.create` | ACP is DocuMentor's only channel to the session; a stored-only message produces no idle event; a message's mentions live on its text part | -| `packages/opencode/src/session/prompt.ts` | `prompt` with no parts and no `noReply`, on a session that already has a user message, stores nothing and runs `loop`; on a session with no user message it stores a zero-part row as upstream does. `createUserMessage` stores the message and its parts with `Session.saveMessage` | DocuMentor's abort-then-answer: after a cancel, an empty ACP prompt answers the user messages that arrived during the cancelled step, which opencode already holds. A v1 client sending `parts: []` gets the same resume; upstream clients send none. A reader never sees a user message without all of its parts | -| `packages/opencode/src/session/prompt.ts` | `PromptInput` accepts `author` and `answerable`; `prompt` returns the stored message without a loop when `answerable` is `false`, as for `noReply`; `runLoop` returns the newest assistant message (the newest message only when the session has no assistant message) instead of throwing when no answerable user message exists; a prompt of any kind, a stored-only message included, commits a pending revert; the loop's model input is built with `attributeAuthors` when the session's metadata is `{ documentor: { kind: "discussion" } }` | A discussion stores human messages as user messages; only an `@mentor` message starts a turn, and that turn must see who said what | -| `packages/schema/src/v1/session.ts`, `packages/core/src/v1/session.ts` | A user message's info carries `author?: {id, name}` and `answerable?: boolean`; `UserAuthor` is exported | DocuMentor records who typed each message in opencode itself, and marks a discussion message as context that no turn answers | -| `packages/opencode/src/session/message-v2.ts` | `latest` skips user messages whose `answerable` is `false`; `toModelMessagesEffect` takes `attributeAuthors` and prefixes the first non-synthetic text part of an authored user message with `Name: `; `authorPrefix` and `isDiscussion` are exported | The message a turn answers is the newest answerable one; attribution is switched by the session kind so ordinary chats send today's prompts | -| `packages/opencode/src/session/compaction.ts` | `serialize` prefixes an authored user message with `Name: ` when the session is a discussion (its `process` passes `MessageV2.isDiscussion(session.metadata)`); `prune` and `turns()` count only user messages whose `answerable` is not `false` | A compacted discussion keeps who said what; side remarks do not shrink the protected or retained window | -| `packages/opencode/src/session/session.ts` | `CreateInput` and `create` take an optional `id`; `create` refuses an id that names an existing session. `saveMessage` publishes a message and its parts in one transaction | DocuMentor shows a new chat under its final id before opencode answers `session/new`. Each part was its own commit: on a network database a reader between them saw the context part of a user message without its text | -| `packages/sdk/js/src/v2/gen/*` | `session.create` takes `id`; `session.prompt` and `session.promptAsync` take `author` and `answerable`, and `UserMessage` declares both | Generated by `packages/sdk/js/script/build.ts` from the server's OpenAPI | -| `packages/opencode/src/acp/event.ts` | `agent_message_chunk`, `agent_thought_chunk`, `tool_call` and `tool_call_update`, live and replayed, carry `_meta.opencode = { messageId, parentId }`: the assistant message and the user message it answers. The subscription caches each assistant message's `parentID` from `message.updated` for the sessions it tracks. On a miss it fetches the message once, shared by concurrent updates; a failed fetch is remembered until the session goes idle, and meanwhile the updates carry `parentId: null`. A session's cache is forgotten when it goes idle, and the whole cache when the event stream disconnects | DocuMentor detects from the ACP stream alone, in order, the moment the agent starts answering a message sent while the turn ran | -| `packages/opencode/src/acp/event.ts` | A replayed `user_message_chunk` carries `_meta.opencode = { messageId, parentId: null, author?, answerable?, partMetadata? }` | DocuMentor rebuilds a discussion from the replay with authors and mention metadata | -| Fork cases in `packages/opencode/test/acp/event.test.ts`, `test/acp/service-session.test.ts`, `test/session/prompt.test.ts`, `test/session/message-v2.test.ts`, `test/session/compaction.test.ts` and `test/session/session.test.ts` | Tests named `documentor: …` | They pin the ACP and session rows above; a rebase keeps them | -| `packages/core/src/integration.ts` | An OAuth attempt's credential is stored before the attempt reads as complete, inside one update of the attempts map | The attempt was marked complete first and the credential written after. On SQLite nothing could look in between; on a network database a caller that saw "complete" could list no credential | -| `patches/effect@4.0.0-beta.83.patch` | Adds Effect's own fix for re-entrant interruption, first released in `4.0.0-beta.100` | A fiber that woke another fiber which interrupted it synchronously had its continuation stack corrupted once its cleanup awaited anything. With Postgres that leaked a pooled connection. Drop these hunks when upstream moves to `beta.100` or later | -| `turbo.json` | `OPENCODE_TEST_DATABASE_URL` passes through to every task; `@opencode-ai/effect-drizzle-pg#test` is a task | turbo strips variables it is not told about | -| Test helpers: `packages/core/test/preload.ts`, `packages/opencode/test/preload.ts`, `packages/opencode/test/fixture/db.ts`, `packages/opencode/test/server/httpapi-exercise/environment.ts`, `packages/sdk-next/test/embedded.test.ts` | Ask for a throwaway schema; no database file to delete | | +| File | What differs | Why | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The 11 table files: `packages/core/src/{account,credential,event,permission,project,session,share}/sql.ts`, `control-plane/workspace.sql.ts`, `data-migration.sql.ts`, `database/schema.sql.ts`, `database/path.ts` | Postgres table builder; `count`, `jsonb` and `text` columns from `effect-drizzle-pg`; time columns are `double precision` | Written by `documentor/scripts/convert-schema.py`, never by hand | +| `packages/core/src/database/schema.gen.ts`, `packages/core/schema.json`, `packages/core/drizzle.config.ts` | The Postgres baseline, as one statement batch | Generated; see "Regenerating the baseline" | +| `packages/core/src/database/database.ts` | Connects with `@effect/sql-pg`; verifies collation and migrations and changes nothing; a throwaway schema per layer when `OPENCODE_DATABASE_EPHEMERAL=1` | The service itself | +| `packages/core/src/database/migration.ts` | `migrate`, `migrateAll`, `verify`, `pending`, `BASELINE_IDS`, `ported` | Schema work is a command, not a start-up side effect | +| `packages/core/src/project/directories.ts` | `Transaction` is `Database.Transaction` | It was typed against the SQLite client | +| `packages/core/src/flag/flag.ts` | No `OPENCODE_DB` | Nothing reads it | +| `packages/core/package.json` | Depends on `@effect/sql-pg`, `pg`, `@opencode-ai/effect-drizzle-pg`; tests run with `--timeout 30000` | Each test database is a real schema, about 110 ms to build | +| `packages/opencode/src/cli/cmd/db.ts` | `db `, `db migrate [--all]`, `db provision`, `db grant-read`, all outside the application runtime | The runtime's database service refuses the schemas these commands exist to create and migrate | +| `packages/opencode/src/session/processor.ts` | One lock per processor around each read-then-write of a tool part; `tool-input-delta` and `tool-input-end` do not read a part the processor already tracks | The processor and the running tool both update a tool part. SQLite made each update atomic; a network database lets them interleave, and the tool's metadata was lost. Each read is a round trip, and a large edit streams thousands of deltas | +| `packages/opencode/src/acp/service.ts` | `prompt` passes a `messageId` that starts with `msg` to v1 as `messageID`, for a normal prompt and a slash command; an empty prompt passes none. `newSession` passes a `_meta.opencode.sessionId` that starts with `ses` to `session.create` as `id` | DocuMentor names the user message a prompt creates and the session a new chat creates, so their optimistic, live and durable copies share one id. ACP clients that send UUIDs, or no `_meta`, keep opencode-minted ids | +| `packages/opencode/src/acp/service.ts` | `prompt` reads `_meta.documentor = { author, answerable, noReply, system, partMetadata }` and forwards each well-formed field to v1, `partMetadata` as the first text part's `metadata`; a prompt with `noReply` or `answerable: false` is sent without the idle wait and answers `end_turn` at once; a request carrying `_meta.documentor` is never read as a slash command; an answer that is a user message is `end_turn` without usage; `newSession` reads `_meta.documentor.metadata` into `session.create` | ACP is DocuMentor's only channel to the session; a stored-only message produces no idle event; a message's mentions live on its text part | +| `packages/opencode/src/session/prompt.ts` | `prompt` with no parts and no `noReply`, on a session that already has a user message, stores nothing and runs `loop`; on a session with no user message it stores a zero-part row as upstream does. `createUserMessage` stores the message and its parts with `Session.saveMessage` | DocuMentor's abort-then-answer: after a cancel, an empty ACP prompt answers the user messages that arrived during the cancelled step, which opencode already holds. A v1 client sending `parts: []` gets the same resume; upstream clients send none. A reader never sees a user message without all of its parts | +| `packages/opencode/src/session/prompt.ts` | `PromptInput` accepts `author` and `answerable`; `prompt` returns the stored message without a loop when `answerable` is `false`, as for `noReply`; `runLoop` returns the newest assistant message (the newest message only when the session has no assistant message) instead of throwing when no answerable user message exists; a prompt of any kind, a stored-only message included, commits a pending revert; the loop's model input is built with `attributeAuthors` when the session's metadata is `{ documentor: { kind: "discussion" } }` | A discussion stores human messages as user messages; only an `@mentor` message starts a turn, and that turn must see who said what | +| `packages/schema/src/v1/session.ts`, `packages/core/src/v1/session.ts` | A user message's info carries `author?: {id, name}` and `answerable?: boolean`; `UserAuthor` is exported | DocuMentor records who typed each message in opencode itself, and marks a discussion message as context that no turn answers | +| `packages/opencode/src/session/message-v2.ts` | `latest` skips user messages whose `answerable` is `false`; `toModelMessagesEffect` takes `attributeAuthors` and prefixes the first non-synthetic text part of an authored user message with `Name: `; `authorPrefix` and `isDiscussion` are exported | The message a turn answers is the newest answerable one; attribution is switched by the session kind so ordinary chats send today's prompts | +| `packages/opencode/src/session/compaction.ts` | `serialize` prefixes an authored user message with `Name: ` when the session is a discussion (its `process` passes `MessageV2.isDiscussion(session.metadata)`); `prune` and `turns()` count only user messages whose `answerable` is not `false` | A compacted discussion keeps who said what; side remarks do not shrink the protected or retained window | +| `packages/opencode/src/session/session.ts` | `CreateInput` and `create` take an optional `id`; `create` refuses an id that names an existing session. `saveMessage` publishes a message and its parts in one transaction | DocuMentor shows a new chat under its final id before opencode answers `session/new`. Each part was its own commit: on a network database a reader between them saw the context part of a user message without its text | +| `packages/core/src/v1/config/config.ts`, `packages/opencode/src/session/llm.ts`, `packages/opencode/src/agent/agent.ts` | `experimental.openTelemetryChatContent` is a config key; both `experimental_telemetry` blocks pass it as the AI SDK's `recordInputs` and `recordOutputs`, so unless it is `true` the `ai.*` spans carry no prompt and no response text | The prompts DocuMentor sends hold personal data about third parties. Upstream's bare `openTelemetry` boolean records them on every span, and DocuMentor's collector had to strip them downstream | +| `packages/sdk/js/src/v2/gen/*` | `session.create` takes `id`; `session.prompt` and `session.promptAsync` take `author` and `answerable`, `UserMessage` declares both, and `Config.experimental` declares `openTelemetryChatContent` | Generated by `packages/sdk/js/script/build.ts` from the server's OpenAPI | +| `packages/opencode/src/acp/event.ts` | `agent_message_chunk`, `agent_thought_chunk`, `tool_call` and `tool_call_update`, live and replayed, carry `_meta.opencode = { messageId, parentId }`: the assistant message and the user message it answers. The subscription caches each assistant message's `parentID` from `message.updated` for the sessions it tracks. On a miss it fetches the message once, shared by concurrent updates; a failed fetch is remembered until the session goes idle, and meanwhile the updates carry `parentId: null`. A session's cache is forgotten when it goes idle, and the whole cache when the event stream disconnects | DocuMentor detects from the ACP stream alone, in order, the moment the agent starts answering a message sent while the turn ran | +| `packages/opencode/src/acp/event.ts` | A replayed `user_message_chunk` carries `_meta.opencode = { messageId, parentId: null, author?, answerable?, partMetadata? }` | DocuMentor rebuilds a discussion from the replay with authors and mention metadata | +| Fork cases in `packages/opencode/test/acp/event.test.ts`, `test/acp/service-session.test.ts`, `test/session/prompt.test.ts`, `test/session/message-v2.test.ts`, `test/session/compaction.test.ts`, `test/session/session.test.ts` and `test/session/llm.test.ts` | Tests named `documentor: …` | They pin the ACP, session and telemetry rows above; a rebase keeps them | +| `packages/core/src/integration.ts` | An OAuth attempt's credential is stored before the attempt reads as complete, inside one update of the attempts map | The attempt was marked complete first and the credential written after. On SQLite nothing could look in between; on a network database a caller that saw "complete" could list no credential | +| `patches/effect@4.0.0-beta.83.patch` | Adds Effect's own fix for re-entrant interruption, first released in `4.0.0-beta.100` | A fiber that woke another fiber which interrupted it synchronously had its continuation stack corrupted once its cleanup awaited anything. With Postgres that leaked a pooled connection. Drop these hunks when upstream moves to `beta.100` or later | +| `turbo.json` | `OPENCODE_TEST_DATABASE_URL` passes through to every task; `@opencode-ai/effect-drizzle-pg#test` is a task | turbo strips variables it is not told about | +| Test helpers: `packages/core/test/preload.ts`, `packages/opencode/test/preload.ts`, `packages/opencode/test/fixture/db.ts`, `packages/opencode/test/server/httpapi-exercise/environment.ts`, `packages/sdk-next/test/embedded.test.ts` | Ask for a throwaway schema; no database file to delete | | Upstream tests that are changed or deleted are listed, with reasons, in [`upstream-test-exceptions.md`](upstream-test-exceptions.md). @@ -128,7 +129,7 @@ must produce the same `migration` journal; `documentor/scripts/binary-smoke.sh`. 6. Read `upstream-test-exceptions.md` against the rebased tests. A test that upstream rewrote may no longer need its exception. -7. Run `bun test test/acp/event.test.ts test/acp/service-session.test.ts test/session/prompt.test.ts test/session/message-v2.test.ts test/session/compaction.test.ts -t "documentor:"` in `packages/opencode`. These pin the ACP and session changes DocuMentor's queued messages and project discussions depend on; a failure means upstream moved the code they patch. +7. Run `bun test test/acp/event.test.ts test/acp/service-session.test.ts test/session/prompt.test.ts test/session/message-v2.test.ts test/session/compaction.test.ts test/session/llm.test.ts -t "documentor:"` in `packages/opencode`. These pin the ACP, session and telemetry changes DocuMentor's queued messages, project discussions and trace privacy depend on; a failure means upstream moved the code they patch. A new upstream test that fails only on Postgres is most often one of two things. Either it reads state the instant a call returns, before a background diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 7ebb4b69b023..47063a7749df 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -173,6 +173,10 @@ export const Info = Schema.Struct({ openTelemetry: Schema.optional(Schema.Boolean).annotate({ description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)", }), + openTelemetryChatContent: Schema.optional(Schema.Boolean).annotate({ + description: + "Record the prompt and the response text on the AI SDK spans. Unless true, the spans carry model, usage, timing and ids only", + }), primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Tools that should only be available to primary agents.", }), diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 536a642fe49f..78e574ad80a0 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -388,6 +388,9 @@ const layer = Layer.effect( const params = { experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry, + // Same switch as session.llm: the generated agent's prompt stays off the span unless asked for. + recordInputs: cfg.experimental?.openTelemetryChatContent === true, + recordOutputs: cfg.experimental?.openTelemetryChatContent === true, tracer, metadata: { userId: cfg.username ?? "unknown", diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a99f8acff20c..45b581a65f6c 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -343,6 +343,10 @@ const live: Layer.Layer< }), experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry, + // The AI SDK records the prompt and the reply unless each flag is + // exactly false, so an absent openTelemetryChatContent records nothing. + recordInputs: cfg.experimental?.openTelemetryChatContent === true, + recordOutputs: cfg.experimental?.openTelemetryChatContent === true, functionId: "session.llm", tracer: telemetryTracer, metadata: { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index ed0ad2022e21..f23c008a2a3f 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -28,6 +28,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { ProviderError } from "@/provider/error" +import * as OtelTracer from "@effect/opentelemetry/Tracer" +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base" type ConfigModel = NonNullable[string]["models"]>[string] @@ -1393,6 +1395,125 @@ describe("session.llm.stream", () => { { config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) }, ) + // The AI SDK writes the prompt and the reply onto its spans unless told not + // to. experimental.openTelemetryChatContent is that switch, and absent means + // off, so a deployment that forgets it loses chat content from its traces + // rather than leaking it. Everything else on the span stays either way. + for (const chatContent of [false, true]) { + it.instance( + `documentor: AI SDK spans carry chat content only when openTelemetryChatContent is true (${chatContent})`, + () => + Effect.gen(function* () { + const model = loadFixture("openai", "gpt-5.2").model + waitRequest( + "/responses", + createEventResponse( + [ + { + type: "response.created", + response: { + id: "resp-otel", + created_at: Math.floor(Date.now() / 1000), + model: model.id, + service_tier: null, + }, + }, + { + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "item-otel", status: "in_progress", role: "assistant", content: [] }, + }, + { + type: "response.content_part.added", + item_id: "item-otel", + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }, + { type: "response.output_text.delta", item_id: "item-otel", delta: "Secret reply", logprobs: null }, + { + type: "response.completed", + response: { + incomplete_details: null, + usage: { + input_tokens: 1, + input_tokens_details: null, + output_tokens: 1, + output_tokens_details: null, + }, + service_tier: null, + }, + }, + ], + true, + ), + ) + + const exporter = new InMemorySpanExporter() + const tracing = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }) + + const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id)) + const sessionID = SessionID.make("session-test-otel") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + const user = { + id: MessageID.make("msg_user-otel"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" }, + } satisfies SessionV1.User + + yield* drain({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Secret prompt" }], + tools: {}, + }).pipe(Effect.provideService(OtelTracer.OtelTracer, tracing.getTracer("test"))) + yield* Effect.promise(() => tracing.forceFlush()) + + const spans = exporter.getFinishedSpans() + const outer = spans.find((span) => span.name === "ai.streamText") + const inner = spans.find((span) => span.name === "ai.streamText.doStream") + expect(outer).toBeDefined() + expect(inner).toBeDefined() + + const content = (keys: string[]) => + keys.filter((key) => key.startsWith("ai.prompt") || key === "ai.response.text") + const serialized = JSON.stringify(spans.map((span) => span.attributes)) + if (chatContent) { + expect(outer!.attributes["ai.response.text"]).toBe("Secret reply") + expect(content(Object.keys(inner!.attributes))).toContain("ai.prompt.messages") + expect(serialized).toContain("Secret prompt") + } else { + expect(content(Object.keys(outer!.attributes))).toEqual([]) + expect(content(Object.keys(inner!.attributes))).toEqual([]) + expect(serialized).not.toContain("Secret prompt") + expect(serialized).not.toContain("Secret reply") + } + // What the spans exist for survives the switch in both positions. + expect(outer!.attributes["ai.telemetry.metadata.sessionId"]).toBe(sessionID) + expect(outer!.attributes["session.id"]).toBe(sessionID) + expect(inner!.attributes["ai.usage.outputTokens"]).toBe(1) + expect(inner!.attributes["gen_ai.request.model"]).toBe(resolved.api.id) + }), + { + config: () => ({ + ...openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`), + experimental: chatContent ? { openTelemetry: true, openTelemetryChatContent: true } : { openTelemetry: true }, + }), + }, + ) + } + it.instance( "keeps supported OpenAI models on AI SDK path when native flag is off", () => diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 05b3012637bc..d86af52218c3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2032,6 +2032,7 @@ export type Config = { disable_paste_summary?: boolean batch_tool?: boolean openTelemetry?: boolean + openTelemetryChatContent?: boolean primary_tools?: Array continue_loop_on_deny?: boolean mcp_timeout?: number