@sentry/junior 0.69.0 → 0.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  getAgentPlugins,
20
20
  getAgentTurnSessionRecord,
21
21
  getInterruptionMarker,
22
+ initConversationContext,
22
23
  listAgentTurnSessionSummaries,
23
24
  listAgentTurnSessionSummariesForConversation,
24
25
  loadConnectedMcpProviders,
@@ -32,12 +33,13 @@ import {
32
33
  resolveSlackChannelTypeFromMessage,
33
34
  resolveSlackConversationContext,
34
35
  setAgentPlugins,
36
+ setConversationTitle,
35
37
  splitSlackReplyText,
36
38
  truncateStatusText,
37
39
  upsertAgentTurnSessionRecord,
38
40
  validateAgentPlugins,
39
41
  verifySlackDirectCredentialSubject
40
- } from "./chunk-N3MORKTH.js";
42
+ } from "./chunk-HOGQL2H6.js";
41
43
  import {
42
44
  discoverSkills,
43
45
  findSkillByName,
@@ -6545,6 +6547,7 @@ import {
6545
6547
  agentPluginProviderAccountSchema
6546
6548
  } from "@sentry/junior-plugin-api";
6547
6549
  var finiteNumberSchema = z.number().refine(Number.isFinite);
6550
+ var httpStatusSchema = z.number().int().min(100).max(599);
6548
6551
  var providerNameSchema = z.string().regex(/^[a-z][a-z0-9-]*$/);
6549
6552
  var sandboxEgressGrantSchema = agentPluginGrantSchema;
6550
6553
  var sandboxEgressCredentialContextSchema = z.object({
@@ -6584,7 +6587,7 @@ var sandboxEgressPermissionDeniedSignalSchema = z.object({
6584
6587
  provider: providerNameSchema,
6585
6588
  source: z.literal("upstream"),
6586
6589
  sso: z.string().optional(),
6587
- status: z.literal(403),
6590
+ status: httpStatusSchema,
6588
6591
  upstreamHost: z.string().min(1),
6589
6592
  upstreamPath: z.string().min(1),
6590
6593
  createdAtMs: finiteNumberSchema
@@ -8482,6 +8485,10 @@ function stringField(record, key) {
8482
8485
  const value = record[key];
8483
8486
  return typeof value === "string" ? value : "";
8484
8487
  }
8488
+ function numberField(record, key) {
8489
+ const value = record[key];
8490
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
8491
+ }
8485
8492
  function stringListField(record, key) {
8486
8493
  const value = record[key];
8487
8494
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
@@ -8502,14 +8509,15 @@ function upstreamPermissionDeniedText(value) {
8502
8509
  return void 0;
8503
8510
  }
8504
8511
  const signal = value.permission_denied;
8505
- if (signal.source !== "upstream" || signal.status !== 403) {
8512
+ if (signal.source !== "upstream") {
8506
8513
  return void 0;
8507
8514
  }
8508
8515
  const provider = stringField(signal, "provider");
8509
8516
  const message = stringField(signal, "message");
8510
8517
  const upstreamHost = stringField(signal, "upstreamHost");
8511
8518
  const upstreamPath2 = stringField(signal, "upstreamPath");
8512
- if (!provider || !message || !upstreamHost || !upstreamPath2) {
8519
+ const status = numberField(signal, "status");
8520
+ if (!provider || !message || !upstreamHost || !upstreamPath2 || !status) {
8513
8521
  return void 0;
8514
8522
  }
8515
8523
  const grant = isRecord3(signal.grant) ? signal.grant : {};
@@ -8535,7 +8543,7 @@ function upstreamPermissionDeniedText(value) {
8535
8543
  ...grantRequirements.map((item) => `- ${item}`)
8536
8544
  ] : [],
8537
8545
  `Upstream: ${upstreamHost}${upstreamPath2}`,
8538
- "Status: 403",
8546
+ `Status: ${status}`,
8539
8547
  ...acceptedPermissions ? [`Accepted provider permissions: ${acceptedPermissions}`] : [],
8540
8548
  ...sso ? [`Provider SSO: ${sso}`] : [],
8541
8549
  ...command ? [`Command: ${command}`] : [],
@@ -8953,12 +8961,41 @@ async function selectPluginGrant(input) {
8953
8961
  plugin: { name: plugin.name },
8954
8962
  log: createAgentPluginLogger(plugin.name),
8955
8963
  request: {
8964
+ ...input.bodyText !== void 0 ? { bodyText: input.bodyText } : {},
8956
8965
  method: input.method,
8957
8966
  url: input.upstreamUrl.toString()
8958
8967
  }
8959
8968
  });
8960
8969
  return result === void 0 ? void 0 : parseGrant(result, plugin.name);
8961
8970
  }
8971
+ async function onPluginEgressResponse(input) {
8972
+ const plugin = agentPluginFor(input.provider);
8973
+ const hook = plugin?.hooks?.onEgressResponse;
8974
+ if (!plugin || !hook) {
8975
+ return {};
8976
+ }
8977
+ let permissionDenied;
8978
+ await hook({
8979
+ plugin: { name: plugin.name },
8980
+ log: createAgentPluginLogger(plugin.name),
8981
+ grant: input.grant,
8982
+ permissionDenied(message) {
8983
+ const trimmed = message.trim();
8984
+ if (!trimmed) {
8985
+ throw new Error(
8986
+ `Plugin "${plugin.name}" onEgressResponse permissionDenied message is empty`
8987
+ );
8988
+ }
8989
+ permissionDenied = { message: trimmed };
8990
+ },
8991
+ request: {
8992
+ method: input.method,
8993
+ url: input.upstreamUrl.toString()
8994
+ },
8995
+ response: input.response
8996
+ });
8997
+ return permissionDenied ? { permissionDenied } : {};
8998
+ }
8962
8999
  function hasEgressCredentialHooks(provider) {
8963
9000
  const hooks = agentPluginFor(provider)?.hooks;
8964
9001
  return Boolean(hooks?.grantForEgress || hooks?.issueCredential);
@@ -17426,6 +17463,7 @@ async function selectSandboxEgressGrant(input) {
17426
17463
  return defaultGrantForProvider(input);
17427
17464
  }
17428
17465
  const pluginGrant = await selectPluginGrant({
17466
+ ...input.bodyText !== void 0 ? { bodyText: input.bodyText } : {},
17429
17467
  provider: input.provider,
17430
17468
  method: input.method,
17431
17469
  upstreamUrl: input.upstreamUrl
@@ -17587,6 +17625,7 @@ async function verifyVercelSandboxOidcToken(token) {
17587
17625
  }
17588
17626
 
17589
17627
  // src/chat/sandbox/egress-proxy.ts
17628
+ import { EgressAuthRequired } from "@sentry/junior-plugin-api";
17590
17629
  var OIDC_TOKEN_HEADER = "vercel-sandbox-oidc-token";
17591
17630
  var FORWARDED_HOST_HEADER = "vercel-forwarded-host";
17592
17631
  var FORWARDED_SCHEME_HEADER = "vercel-forwarded-scheme";
@@ -17616,6 +17655,8 @@ var DECODED_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
17616
17655
  ]);
17617
17656
  var UPSTREAM_TOKEN_REJECTION_STATUS = 401;
17618
17657
  var UPSTREAM_PERMISSION_REJECTION_STATUS = 403;
17658
+ var GRANT_SELECTION_BODY_TEXT_LIMIT_BYTES = 64 * 1024;
17659
+ var RESPONSE_BODY_TEXT_LIMIT_BYTES = 64 * 1024;
17619
17660
  function jsonError(message, status) {
17620
17661
  return Response.json({ error: message }, { status });
17621
17662
  }
@@ -17714,6 +17755,9 @@ function githubPermissionHeaders(upstream) {
17714
17755
  function permissionDeniedMessage(provider, grant) {
17715
17756
  return `${provider} returned HTTP 403 after Junior injected the ${grant.name} grant. Junior forwarded the request; this is not a local runtime block.`;
17716
17757
  }
17758
+ function isEgressAuthRequired(error) {
17759
+ return error instanceof EgressAuthRequired || error instanceof Error && error.name === "EgressAuthRequired";
17760
+ }
17717
17761
  function logSandboxEgressUpstreamRequest(input) {
17718
17762
  if (!shouldLogSandboxEgressInfo()) {
17719
17763
  return;
@@ -17822,6 +17866,78 @@ async function requestBodyBytes(request) {
17822
17866
  }
17823
17867
  return await request.arrayBuffer();
17824
17868
  }
17869
+ function isGrantSelectionBodyVisible(input) {
17870
+ return input.provider === "github" && input.upstreamUrl.hostname.toLowerCase() === "api.github.com" && input.upstreamUrl.pathname.toLowerCase().endsWith("/graphql");
17871
+ }
17872
+ function requestBodyText(body) {
17873
+ if (body === void 0 || body.byteLength > GRANT_SELECTION_BODY_TEXT_LIMIT_BYTES) {
17874
+ return void 0;
17875
+ }
17876
+ return new TextDecoder().decode(body);
17877
+ }
17878
+ function responseContentLength(upstream) {
17879
+ const raw = upstream.headers.get("content-length");
17880
+ if (!raw) {
17881
+ return void 0;
17882
+ }
17883
+ const parsed = Number(raw);
17884
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : void 0;
17885
+ }
17886
+ async function responseTextWithinLimit(upstream, maxBytes) {
17887
+ const limit = Math.min(
17888
+ Math.max(0, Math.floor(maxBytes)),
17889
+ RESPONSE_BODY_TEXT_LIMIT_BYTES
17890
+ );
17891
+ if (limit <= 0) {
17892
+ return void 0;
17893
+ }
17894
+ const contentLength = responseContentLength(upstream);
17895
+ if (contentLength !== void 0 && contentLength > limit) {
17896
+ return void 0;
17897
+ }
17898
+ let clone;
17899
+ try {
17900
+ clone = upstream.clone();
17901
+ } catch {
17902
+ return void 0;
17903
+ }
17904
+ const body = clone.body;
17905
+ if (!body) {
17906
+ return "";
17907
+ }
17908
+ const reader = body.getReader();
17909
+ const chunks = [];
17910
+ let bytes = 0;
17911
+ try {
17912
+ while (true) {
17913
+ const { done, value } = await reader.read();
17914
+ if (done) {
17915
+ break;
17916
+ }
17917
+ if (!value) {
17918
+ continue;
17919
+ }
17920
+ bytes += value.byteLength;
17921
+ if (bytes > limit) {
17922
+ await reader.cancel().catch(() => void 0);
17923
+ return void 0;
17924
+ }
17925
+ chunks.push(value);
17926
+ }
17927
+ } catch {
17928
+ await reader.cancel().catch(() => void 0);
17929
+ return void 0;
17930
+ } finally {
17931
+ reader.releaseLock();
17932
+ }
17933
+ const combined = new Uint8Array(bytes);
17934
+ let offset = 0;
17935
+ for (const chunk of chunks) {
17936
+ combined.set(chunk, offset);
17937
+ offset += chunk.byteLength;
17938
+ }
17939
+ return new TextDecoder().decode(combined);
17940
+ }
17825
17941
  function requestHeaders(request, lease, upstreamHost) {
17826
17942
  const headers = new Headers();
17827
17943
  request.headers.forEach((value, key) => {
@@ -17956,7 +18072,14 @@ async function proxySandboxEgressRequest(request, deps = {}) {
17956
18072
  403
17957
18073
  );
17958
18074
  }
18075
+ let body;
18076
+ let bodyRead = false;
18077
+ if (isGrantSelectionBodyVisible({ provider, upstreamUrl })) {
18078
+ body = await requestBodyBytes(request);
18079
+ bodyRead = true;
18080
+ }
17959
18081
  const grantSelection = await selectSandboxEgressGrant({
18082
+ bodyText: requestBodyText(body),
17960
18083
  provider,
17961
18084
  method: request.method,
17962
18085
  upstreamUrl
@@ -18067,7 +18190,9 @@ async function proxySandboxEgressRequest(request, deps = {}) {
18067
18190
  }
18068
18191
  const fetchImpl = deps.fetch ?? fetch;
18069
18192
  const headers = requestHeaders(request, lease, upstreamUrl.hostname);
18070
- const body = await requestBodyBytes(request);
18193
+ if (!bodyRead) {
18194
+ body = await requestBodyBytes(request);
18195
+ }
18071
18196
  const intercepted = await deps.interceptHttp?.({
18072
18197
  provider,
18073
18198
  request: new Request(upstreamUrl, {
@@ -18086,6 +18211,93 @@ async function proxySandboxEgressRequest(request, deps = {}) {
18086
18211
  ...body !== void 0 ? { body } : {},
18087
18212
  redirect: "manual"
18088
18213
  });
18214
+ try {
18215
+ const effects = await onPluginEgressResponse({
18216
+ provider,
18217
+ grant: lease.grant,
18218
+ method: request.method,
18219
+ upstreamUrl,
18220
+ response: {
18221
+ headers: new Headers(upstream.headers),
18222
+ readText: async (maxBytes) => await responseTextWithinLimit(upstream, maxBytes),
18223
+ status: upstream.status
18224
+ }
18225
+ });
18226
+ if (effects.permissionDenied) {
18227
+ await setSandboxEgressPermissionDeniedSignal(credentialContext, {
18228
+ provider,
18229
+ grant: lease.grant,
18230
+ ...lease.account ? { account: lease.account } : {},
18231
+ message: effects.permissionDenied.message,
18232
+ source: "upstream",
18233
+ status: upstream.status,
18234
+ upstreamHost: upstreamUrl.hostname,
18235
+ upstreamPath: displayedUpstreamPath(upstreamUrl),
18236
+ ...provider === "github" ? githubPermissionHeaders(upstream) : {}
18237
+ });
18238
+ logWarn(
18239
+ "sandbox_egress_upstream_permission_classified",
18240
+ {},
18241
+ {
18242
+ ...egressAttributes({
18243
+ egressId: activeEgressId,
18244
+ grantAccess: lease.grant.access,
18245
+ grantName: lease.grant.name,
18246
+ grantReason: lease.grant.reason,
18247
+ host: upstreamUrl.hostname,
18248
+ method: request.method,
18249
+ path: upstreamUrl.pathname,
18250
+ provider,
18251
+ status: upstream.status
18252
+ }),
18253
+ ...routingAttributes(request, upstreamUrl),
18254
+ ...upstreamPermissionAttributes(provider, upstream)
18255
+ },
18256
+ "Sandbox egress plugin classified upstream response as permission denied"
18257
+ );
18258
+ }
18259
+ } catch (error) {
18260
+ if (!isEgressAuthRequired(error)) {
18261
+ throw error;
18262
+ }
18263
+ await clearSandboxEgressCredentialLease(
18264
+ provider,
18265
+ lease.grant.name,
18266
+ credentialContext
18267
+ );
18268
+ await setSandboxEgressAuthRequiredSignal(credentialContext, {
18269
+ provider,
18270
+ grant: lease.grant,
18271
+ ...error.authorization ?? lease.authorization ? { authorization: error.authorization ?? lease.authorization } : {},
18272
+ message: error.message
18273
+ });
18274
+ logWarn(
18275
+ "sandbox_egress_upstream_auth_required_classified",
18276
+ {},
18277
+ {
18278
+ ...egressAttributes({
18279
+ egressId: activeEgressId,
18280
+ grantAccess: lease.grant.access,
18281
+ grantName: lease.grant.name,
18282
+ grantReason: lease.grant.reason,
18283
+ host: upstreamUrl.hostname,
18284
+ method: request.method,
18285
+ path: upstreamUrl.pathname,
18286
+ provider,
18287
+ status: upstream.status
18288
+ }),
18289
+ ...routingAttributes(request, upstreamUrl),
18290
+ ...upstreamPermissionAttributes(provider, upstream)
18291
+ },
18292
+ "Sandbox egress plugin classified upstream response as auth required"
18293
+ );
18294
+ await upstream.body?.cancel().catch(() => void 0);
18295
+ return authRequiredResponse({
18296
+ provider,
18297
+ grant: lease.grant,
18298
+ message: error.message
18299
+ });
18300
+ }
18089
18301
  logSandboxEgressUpstreamRequest({
18090
18302
  egressId: activeEgressId,
18091
18303
  grantAccess: lease.grant.access,
@@ -19034,6 +19246,7 @@ function createSlackTurnRuntime(deps) {
19034
19246
  text: args.text
19035
19247
  });
19036
19248
  }
19249
+ await args.onInputCommitted?.();
19037
19250
  };
19038
19251
  return {
19039
19252
  async handleNewMention(thread, message, hooks) {
@@ -19222,6 +19435,7 @@ function createSlackTurnRuntime(deps) {
19222
19435
  message,
19223
19436
  decision: { shouldReply: false, reason },
19224
19437
  context: threadContext,
19438
+ onInputCommitted: hooks.onInputCommitted,
19225
19439
  text: combinedText
19226
19440
  });
19227
19441
  return;
@@ -19258,6 +19472,7 @@ function createSlackTurnRuntime(deps) {
19258
19472
  message,
19259
19473
  decision,
19260
19474
  context: threadContext,
19475
+ onInputCommitted: hooks.onInputCommitted,
19261
19476
  preparedState,
19262
19477
  text: combinedText
19263
19478
  });
@@ -19269,6 +19484,7 @@ function createSlackTurnRuntime(deps) {
19269
19484
  message,
19270
19485
  decision,
19271
19486
  context: threadContext,
19487
+ onInputCommitted: hooks.onInputCommitted,
19272
19488
  preparedState,
19273
19489
  text: combinedText
19274
19490
  });
@@ -20694,12 +20910,13 @@ function createReplyToThread(deps) {
20694
20910
  updateConversationStats
20695
20911
  });
20696
20912
  if (conversationId) {
20913
+ const turnStartedAtMs = message.metadata.dateSent.getTime();
20697
20914
  void recordAgentTurnSessionSummary({
20698
20915
  channelName,
20699
20916
  conversationId,
20700
20917
  sessionId: turnId,
20701
20918
  sliceId: 1,
20702
- startedAtMs: message.metadata.dateSent.getTime(),
20919
+ startedAtMs: turnStartedAtMs,
20703
20920
  state: "running",
20704
20921
  surface: "slack",
20705
20922
  requester,
@@ -20710,12 +20927,41 @@ function createReplyToThread(deps) {
20710
20927
  error,
20711
20928
  "agent_turn_summary_record_failed",
20712
20929
  turnTraceContext,
20713
- {
20714
- "app.agent.turn.state": "running"
20715
- },
20930
+ { "app.agent.turn.state": "running" },
20716
20931
  "Failed to record running turn summary"
20717
20932
  );
20718
20933
  });
20934
+ void initConversationContext(conversationId, {
20935
+ channelName,
20936
+ originSurface: "slack",
20937
+ originRequester: requester,
20938
+ startedAtMs: turnStartedAtMs
20939
+ }).catch((error) => {
20940
+ logException(
20941
+ error,
20942
+ "conversation_details_context_init_failed",
20943
+ turnTraceContext,
20944
+ { "app.agent.turn.state": "running" },
20945
+ "Failed to init conversation context at turn start"
20946
+ );
20947
+ });
20948
+ const existingAssistantTitle = preparedState.artifacts.assistantTitle?.trim();
20949
+ if (existingAssistantTitle) {
20950
+ void setConversationTitle(conversationId, {
20951
+ displayTitle: existingAssistantTitle,
20952
+ ...preparedState.artifacts.assistantTitleSourceMessageId ? {
20953
+ titleSourceMessageId: preparedState.artifacts.assistantTitleSourceMessageId
20954
+ } : {}
20955
+ }).catch((error) => {
20956
+ logException(
20957
+ error,
20958
+ "conversation_details_title_refresh_failed",
20959
+ turnTraceContext,
20960
+ { "app.agent.turn.state": "running" },
20961
+ "Failed to refresh conversation title from artifacts"
20962
+ );
20963
+ });
20964
+ }
20719
20965
  }
20720
20966
  setTags({
20721
20967
  conversationId
@@ -20791,6 +21037,7 @@ function createReplyToThread(deps) {
20791
21037
  let persistedAtLeastOnce = false;
20792
21038
  let shouldPersistFailureState = true;
20793
21039
  let latestArtifacts = preparedState.artifacts;
21040
+ let assistantTitleArtifacts = {};
20794
21041
  try {
20795
21042
  const loadedPiMessages = await loadPiMessagesForTurn({
20796
21043
  conversationId,
@@ -20833,6 +21080,54 @@ function createReplyToThread(deps) {
20833
21080
  runId,
20834
21081
  threadId
20835
21082
  });
21083
+ void assistantTitleTask.then(async (titleUpdateResult) => {
21084
+ if (!titleUpdateResult) return;
21085
+ assistantTitleArtifacts = {
21086
+ assistantTitleSourceMessageId: titleUpdateResult.sourceMessageId,
21087
+ ...titleUpdateResult.title ? { assistantTitle: titleUpdateResult.title } : {}
21088
+ };
21089
+ latestArtifacts = {
21090
+ ...latestArtifacts,
21091
+ ...assistantTitleArtifacts
21092
+ };
21093
+ if (conversationId && titleUpdateResult.title) {
21094
+ try {
21095
+ await setConversationTitle(conversationId, {
21096
+ displayTitle: titleUpdateResult.title,
21097
+ titleSourceMessageId: titleUpdateResult.sourceMessageId
21098
+ });
21099
+ } catch (error) {
21100
+ logException(
21101
+ error,
21102
+ "conversation_details_title_set_failed",
21103
+ turnTraceContext,
21104
+ {},
21105
+ "Failed to set conversation title in details record"
21106
+ );
21107
+ }
21108
+ }
21109
+ try {
21110
+ await persistThreadState(thread, {
21111
+ artifacts: latestArtifacts
21112
+ });
21113
+ } catch (error) {
21114
+ logException(
21115
+ error,
21116
+ "assistant_title_artifact_persist_failed",
21117
+ turnTraceContext,
21118
+ {},
21119
+ "Failed to persist async assistant title artifact state"
21120
+ );
21121
+ }
21122
+ }).catch((error) => {
21123
+ logException(
21124
+ error,
21125
+ "assistant_title_task_failed",
21126
+ turnTraceContext,
21127
+ {},
21128
+ "Async assistant title task failed"
21129
+ );
21130
+ });
20836
21131
  const toolChannelId = preparedState.artifacts.assistantContextChannelId ?? channelId;
20837
21132
  const resolveSteeringMessages = async (queuedMessages) => {
20838
21133
  return await Promise.all(
@@ -20911,8 +21206,13 @@ function createReplyToThread(deps) {
20911
21206
  });
20912
21207
  },
20913
21208
  onArtifactStateUpdated: async (artifacts) => {
20914
- latestArtifacts = artifacts;
20915
- await persistThreadState(thread, { artifacts });
21209
+ latestArtifacts = {
21210
+ ...artifacts,
21211
+ ...assistantTitleArtifacts
21212
+ };
21213
+ await persistThreadState(thread, {
21214
+ artifacts: latestArtifacts
21215
+ });
20916
21216
  },
20917
21217
  onAuthPending: async (pendingAuth) => {
20918
21218
  await applyPendingAuthUpdate({
@@ -21013,24 +21313,26 @@ function createReplyToThread(deps) {
21013
21313
  await sent.delete();
21014
21314
  }
21015
21315
  }
21016
- const titleUpdateResult = await assistantTitleTask;
21017
- if (titleUpdateResult) {
21018
- artifactStatePatch.assistantTitleSourceMessageId = titleUpdateResult.sourceMessageId;
21019
- if (titleUpdateResult.title) {
21020
- artifactStatePatch.assistantTitle = titleUpdateResult.title;
21021
- }
21022
- }
21023
21316
  const completedState = buildDeliveredTurnStatePatch({
21024
- artifactStatePatch,
21025
- artifacts: preparedState.artifacts,
21317
+ artifactStatePatch: {
21318
+ ...artifactStatePatch,
21319
+ ...assistantTitleArtifacts
21320
+ },
21321
+ artifacts: latestArtifacts,
21026
21322
  conversation: preparedState.conversation,
21027
21323
  reply,
21028
21324
  sessionId: turnId,
21029
21325
  userMessageId: preparedState.userMessageId
21030
21326
  });
21327
+ if (completedState.artifacts) {
21328
+ latestArtifacts = completedState.artifacts;
21329
+ }
21031
21330
  await persistThreadState(thread, {
21032
21331
  ...completedState
21033
21332
  });
21333
+ if (completedState.artifacts && (assistantTitleArtifacts.assistantTitle !== void 0 || assistantTitleArtifacts.assistantTitleSourceMessageId !== void 0) && (completedState.artifacts.assistantTitle !== assistantTitleArtifacts.assistantTitle || completedState.artifacts.assistantTitleSourceMessageId !== assistantTitleArtifacts.assistantTitleSourceMessageId)) {
21334
+ await persistThreadState(thread, { artifacts: latestArtifacts });
21335
+ }
21034
21336
  if (conversationId) {
21035
21337
  await recordAgentTurnSessionSummary({
21036
21338
  channelName,
@@ -21041,7 +21343,6 @@ function createReplyToThread(deps) {
21041
21343
  sliceId: 1,
21042
21344
  startedAtMs: message.metadata.dateSent.getTime(),
21043
21345
  state: "completed",
21044
- conversationTitle: titleUpdateResult?.title,
21045
21346
  requester,
21046
21347
  destination,
21047
21348
  traceId: getActiveTraceId()
@@ -1,12 +1,31 @@
1
1
  import { type AgentPluginCredentialResult, type AgentPluginGrant, type AgentPluginProviderAccount } from "@sentry/junior-plugin-api";
2
2
  import type { StoredTokens, UserTokenStore } from "@/chat/credentials/user-token-store";
3
3
  export interface EgressGrantInput {
4
+ bodyText?: string;
4
5
  method: string;
5
6
  provider: string;
6
7
  upstreamUrl: URL;
7
8
  }
8
9
  /** Ask a plugin which grant an outbound request needs. */
9
10
  export declare function selectPluginGrant(input: EgressGrantInput): Promise<AgentPluginGrant | undefined>;
11
+ export interface EgressResponseInput {
12
+ grant: AgentPluginGrant;
13
+ method: string;
14
+ provider: string;
15
+ response: {
16
+ headers: Headers;
17
+ readText(maxBytes: number): Promise<string | undefined>;
18
+ status: number;
19
+ };
20
+ upstreamUrl: URL;
21
+ }
22
+ export interface EgressResponseEffects {
23
+ permissionDenied?: {
24
+ message: string;
25
+ };
26
+ }
27
+ /** Let the owning plugin inspect an upstream response without changing pass-through behavior. */
28
+ export declare function onPluginEgressResponse(input: EgressResponseInput): Promise<EgressResponseEffects>;
10
29
  /** Return whether a plugin owns credential issuance for egress. */
11
30
  export declare function hasEgressCredentialHooks(provider: string): boolean;
12
31
  export interface IssueCredentialInput {
@@ -21,6 +21,7 @@ export declare class SandboxEgressCredentialNeededError extends Error {
21
21
  }
22
22
  /** Select the plugin-defined or default grant needed for one outbound request. */
23
23
  export declare function selectSandboxEgressGrant(input: {
24
+ bodyText?: string;
24
25
  method: string;
25
26
  provider: string;
26
27
  upstreamUrl: URL;
@@ -89,7 +89,7 @@ export declare const sandboxEgressPermissionDeniedSignalSchema: z.ZodObject<{
89
89
  provider: z.ZodString;
90
90
  source: z.ZodLiteral<"upstream">;
91
91
  sso: z.ZodOptional<z.ZodString>;
92
- status: z.ZodLiteral<403>;
92
+ status: z.ZodNumber;
93
93
  upstreamHost: z.ZodString;
94
94
  upstreamPath: z.ZodString;
95
95
  createdAtMs: z.ZodNumber;
@@ -0,0 +1,46 @@
1
+ import type { AgentTurnRequester, AgentTurnSurface } from "./turn-session";
2
+ export interface ConversationDetailsRecord {
3
+ conversationId: string;
4
+ /** Generated display title from the LLM. Absent until title has been produced. */
5
+ displayTitle?: string;
6
+ /** The message id used as input when generating the title. */
7
+ titleSourceMessageId?: string;
8
+ /** Slack channel name or equivalent location label. */
9
+ channelName?: string;
10
+ /** Surface on which the conversation was started. */
11
+ originSurface?: AgentTurnSurface;
12
+ /** Requester who initiated the conversation (first turn). */
13
+ originRequester?: AgentTurnRequester;
14
+ /** Timestamp of the first turn in the conversation. */
15
+ startedAtMs?: number;
16
+ }
17
+ /**
18
+ * Record the origin context for a conversation the first time it is seen and
19
+ * refresh the context TTL on later turns without changing the stored origin.
20
+ */
21
+ export declare function initConversationContext(conversationId: string, context: {
22
+ channelName?: string;
23
+ originSurface?: AgentTurnSurface;
24
+ originRequester?: AgentTurnRequester;
25
+ startedAtMs: number;
26
+ }): Promise<void>;
27
+ /**
28
+ * Persist or refresh the LLM-generated title for a conversation.
29
+ *
30
+ * Plain set — no read, no lock.
31
+ */
32
+ export declare function setConversationTitle(conversationId: string, title: {
33
+ displayTitle: string;
34
+ titleSourceMessageId?: string;
35
+ }): Promise<void>;
36
+ /**
37
+ * Read conversation details for a single conversation.
38
+ * Assembles the context and title records in parallel.
39
+ * Returns undefined only when neither context nor title details exist yet.
40
+ */
41
+ export declare function getConversationDetails(conversationId: string): Promise<ConversationDetailsRecord | undefined>;
42
+ /**
43
+ * Bulk-fetch conversation details for a set of conversation ids in parallel.
44
+ * Returns a map from conversationId → record (omits ids with no details).
45
+ */
46
+ export declare function getConversationDetailsForIds(conversationIds: Iterable<string>): Promise<Map<string, ConversationDetailsRecord>>;
@@ -12,7 +12,6 @@ export interface AgentTurnRequester {
12
12
  }
13
13
  export interface AgentTurnSessionRecord {
14
14
  channelName?: string;
15
- conversationTitle?: string;
16
15
  version: number;
17
16
  conversationId: string;
18
17
  cumulativeDurationMs: number;
@@ -39,7 +38,6 @@ export declare function getAgentTurnSessionRecord(conversationId: string, sessio
39
38
  /** Commit stable Pi session state and advance the turn session record. */
40
39
  export declare function upsertAgentTurnSessionRecord(args: {
41
40
  channelName?: string;
42
- conversationTitle?: string;
43
41
  conversationId: string;
44
42
  cumulativeDurationMs?: number;
45
43
  cumulativeUsage?: AgentTurnUsage;
@@ -61,7 +59,6 @@ export declare function upsertAgentTurnSessionRecord(args: {
61
59
  /** Record turn-session metadata without storing conversation messages. */
62
60
  export declare function recordAgentTurnSessionSummary(args: {
63
61
  channelName?: string;
64
- conversationTitle?: string;
65
62
  conversationId: string;
66
63
  cumulativeDurationMs?: number;
67
64
  cumulativeUsage?: AgentTurnUsage;
@@ -29,6 +29,7 @@ import {
29
29
  logInfo,
30
30
  logWarn,
31
31
  soulPathCandidates,
32
+ toOptionalNumber,
32
33
  worldPathCandidates
33
34
  } from "./chunk-BBXYXOJW.js";
34
35
  import {
@@ -2174,7 +2175,6 @@ function parseAgentTurnSessionFields(parsed) {
2174
2175
  return void 0;
2175
2176
  }
2176
2177
  const channelName = typeof parsed.channelName === "string" && parsed.channelName.trim() ? parsed.channelName.trim() : void 0;
2177
- const conversationTitle = typeof parsed.conversationTitle === "string" && parsed.conversationTitle.trim() ? parsed.conversationTitle.trim() : void 0;
2178
2178
  const conversationId = parsed.conversationId;
2179
2179
  const sessionId = parsed.sessionId;
2180
2180
  const sliceId = toFiniteNonNegativeNumber(parsed.sliceId);
@@ -2194,7 +2194,6 @@ function parseAgentTurnSessionFields(parsed) {
2194
2194
  return {
2195
2195
  version,
2196
2196
  ...channelName ? { channelName } : {},
2197
- ...conversationTitle ? { conversationTitle } : {},
2198
2197
  conversationId,
2199
2198
  sessionId,
2200
2199
  sliceId,
@@ -2285,7 +2284,6 @@ function materializeAgentTurnSessionRecord(stored, piMessages) {
2285
2284
  return {
2286
2285
  version: stored.version,
2287
2286
  ...stored.channelName ? { channelName: stored.channelName } : {},
2288
- ...stored.conversationTitle ? { conversationTitle: stored.conversationTitle } : {},
2289
2287
  conversationId: stored.conversationId,
2290
2288
  sessionId: stored.sessionId,
2291
2289
  sliceId: stored.sliceId,
@@ -2347,7 +2345,6 @@ function buildStoredRecord(args) {
2347
2345
  return {
2348
2346
  version: (args.previousVersion ?? 0) + 1,
2349
2347
  ...args.channelName ? { channelName: args.channelName } : {},
2350
- ...args.conversationTitle ? { conversationTitle: args.conversationTitle } : {},
2351
2348
  conversationId: args.conversationId,
2352
2349
  sessionId: args.sessionId,
2353
2350
  sliceId: args.sliceId,
@@ -2408,7 +2405,6 @@ async function updateAgentTurnSessionState(args) {
2408
2405
  state: args.state,
2409
2406
  committedMessageCount: parsed.committedMessageCount,
2410
2407
  ...parsed.channelName ? { channelName: parsed.channelName } : {},
2411
- ...parsed.conversationTitle ? { conversationTitle: parsed.conversationTitle } : {},
2412
2408
  startedAtMs: parsed.startedAtMs,
2413
2409
  lastProgressAtMs: parsed.lastProgressAtMs,
2414
2410
  previousVersion: parsed.version,
@@ -2442,9 +2438,6 @@ async function upsertAgentTurnSessionRecord(args) {
2442
2438
  ttlMs,
2443
2439
  record: buildStoredRecord({
2444
2440
  ...args.channelName ?? existingRecord?.channelName ? { channelName: args.channelName ?? existingRecord?.channelName } : {},
2445
- ...args.conversationTitle ?? existingRecord?.conversationTitle ? {
2446
- conversationTitle: args.conversationTitle ?? existingRecord?.conversationTitle
2447
- } : {},
2448
2441
  conversationId: args.conversationId,
2449
2442
  sessionId: args.sessionId,
2450
2443
  sliceId: args.sliceId,
@@ -2478,9 +2471,6 @@ async function recordAgentTurnSessionSummary(args) {
2478
2471
  {
2479
2472
  version: existing?.version ?? 0,
2480
2473
  ...args.channelName ?? existing?.channelName ? { channelName: args.channelName ?? existing?.channelName } : {},
2481
- ...args.conversationTitle ?? existing?.conversationTitle ? {
2482
- conversationTitle: args.conversationTitle ?? existing?.conversationTitle
2483
- } : {},
2484
2474
  conversationId: args.conversationId,
2485
2475
  sessionId: args.sessionId,
2486
2476
  sliceId: args.sliceId,
@@ -2687,6 +2677,123 @@ function formatSlackConversationRedactedLabel(context) {
2687
2677
  return formatSlackConversationTypeLabel(context.type);
2688
2678
  }
2689
2679
 
2680
+ // src/chat/state/conversation-details.ts
2681
+ import { THREAD_STATE_TTL_MS as THREAD_STATE_TTL_MS2 } from "chat";
2682
+ var CONVERSATION_PREFIX = "junior:conversation";
2683
+ var CONVERSATION_DETAILS_TTL_MS = THREAD_STATE_TTL_MS2;
2684
+ function conversationContextKey(conversationId) {
2685
+ return `${CONVERSATION_PREFIX}:${conversationId}:context`;
2686
+ }
2687
+ function conversationTitleKey(conversationId) {
2688
+ return `${CONVERSATION_PREFIX}:${conversationId}:title`;
2689
+ }
2690
+ function parseAgentTurnRequester2(value) {
2691
+ if (!isRecord(value)) return void 0;
2692
+ const requester = {
2693
+ ...typeof value.email === "string" ? { email: value.email } : {},
2694
+ ...typeof value.fullName === "string" ? { fullName: value.fullName } : {},
2695
+ ...typeof value.slackUserId === "string" ? { slackUserId: value.slackUserId } : {},
2696
+ ...typeof value.slackUserName === "string" ? { slackUserName: value.slackUserName } : {}
2697
+ };
2698
+ return Object.keys(requester).length > 0 ? requester : void 0;
2699
+ }
2700
+ function parseOriginSurface(value) {
2701
+ if (value === "slack" || value === "api" || value === "scheduler" || value === "internal") {
2702
+ return value;
2703
+ }
2704
+ return void 0;
2705
+ }
2706
+ function storedContextFromInput(context) {
2707
+ return {
2708
+ ...context.channelName ? { channelName: context.channelName } : {},
2709
+ ...context.originSurface ? { originSurface: context.originSurface } : {},
2710
+ ...context.originRequester ? { originRequester: context.originRequester } : {},
2711
+ startedAtMs: context.startedAtMs
2712
+ };
2713
+ }
2714
+ function parseContext(value) {
2715
+ if (!isRecord(value)) return void 0;
2716
+ const startedAtMs = toOptionalNumber(value.startedAtMs);
2717
+ if (startedAtMs === void 0) return void 0;
2718
+ return {
2719
+ ...typeof value.channelName === "string" && value.channelName.trim() ? { channelName: value.channelName.trim() } : {},
2720
+ ...parseOriginSurface(value.originSurface) ? { originSurface: parseOriginSurface(value.originSurface) } : {},
2721
+ ...parseAgentTurnRequester2(value.originRequester) ? { originRequester: parseAgentTurnRequester2(value.originRequester) } : {},
2722
+ startedAtMs
2723
+ };
2724
+ }
2725
+ function parseTitle(value) {
2726
+ if (!isRecord(value)) return void 0;
2727
+ const displayTitle = typeof value.displayTitle === "string" && value.displayTitle.trim() ? value.displayTitle.trim() : void 0;
2728
+ if (!displayTitle) return void 0;
2729
+ return {
2730
+ displayTitle,
2731
+ ...typeof value.titleSourceMessageId === "string" ? { titleSourceMessageId: value.titleSourceMessageId } : {}
2732
+ };
2733
+ }
2734
+ async function initConversationContext(conversationId, context) {
2735
+ const stateAdapter = getStateAdapter();
2736
+ await stateAdapter.connect();
2737
+ const key2 = conversationContextKey(conversationId);
2738
+ const inserted = await stateAdapter.setIfNotExists(
2739
+ key2,
2740
+ storedContextFromInput(context),
2741
+ CONVERSATION_DETAILS_TTL_MS
2742
+ );
2743
+ if (inserted) return;
2744
+ const existing = parseContext(await stateAdapter.get(key2));
2745
+ if (!existing) {
2746
+ return;
2747
+ }
2748
+ await stateAdapter.set(key2, existing, CONVERSATION_DETAILS_TTL_MS);
2749
+ }
2750
+ async function setConversationTitle(conversationId, title) {
2751
+ const stateAdapter = getStateAdapter();
2752
+ await stateAdapter.connect();
2753
+ await stateAdapter.set(
2754
+ conversationTitleKey(conversationId),
2755
+ {
2756
+ displayTitle: title.displayTitle,
2757
+ ...title.titleSourceMessageId ? { titleSourceMessageId: title.titleSourceMessageId } : {}
2758
+ },
2759
+ CONVERSATION_DETAILS_TTL_MS
2760
+ );
2761
+ }
2762
+ async function getConversationDetails(conversationId) {
2763
+ const stateAdapter = getStateAdapter();
2764
+ await stateAdapter.connect();
2765
+ const [rawContext, rawTitle] = await Promise.all([
2766
+ stateAdapter.get(conversationContextKey(conversationId)),
2767
+ stateAdapter.get(conversationTitleKey(conversationId))
2768
+ ]);
2769
+ const context = parseContext(rawContext);
2770
+ const title = parseTitle(rawTitle);
2771
+ if (!context && !title) return void 0;
2772
+ return {
2773
+ conversationId,
2774
+ ...title?.displayTitle ? { displayTitle: title.displayTitle } : {},
2775
+ ...title?.titleSourceMessageId ? { titleSourceMessageId: title.titleSourceMessageId } : {},
2776
+ ...context?.channelName ? { channelName: context.channelName } : {},
2777
+ ...context?.originSurface ? { originSurface: context.originSurface } : {},
2778
+ ...context?.originRequester ? { originRequester: context.originRequester } : {},
2779
+ ...context?.startedAtMs !== void 0 ? { startedAtMs: context.startedAtMs } : {}
2780
+ };
2781
+ }
2782
+ async function getConversationDetailsForIds(conversationIds) {
2783
+ const uniqueIds = [...new Set(conversationIds)].filter(Boolean);
2784
+ const entries = await Promise.all(
2785
+ uniqueIds.map(async (id) => {
2786
+ const details = await getConversationDetails(id);
2787
+ return details ? [id, details] : void 0;
2788
+ })
2789
+ );
2790
+ const result = /* @__PURE__ */ new Map();
2791
+ for (const entry of entries) {
2792
+ if (entry) result.set(entry[0], entry[1]);
2793
+ }
2794
+ return result;
2795
+ }
2796
+
2690
2797
  export {
2691
2798
  createAgentPluginLogger,
2692
2799
  createPluginState,
@@ -2729,5 +2836,9 @@ export {
2729
2836
  resolveSlackChannelTypeFromMessage,
2730
2837
  resolveSlackConversationContext,
2731
2838
  resolveSlackConversationContextFromThreadId,
2732
- formatSlackConversationRedactedLabel
2839
+ formatSlackConversationRedactedLabel,
2840
+ initConversationContext,
2841
+ setConversationTitle,
2842
+ getConversationDetails,
2843
+ getConversationDetailsForIds
2733
2844
  };
package/dist/cli/init.js CHANGED
@@ -31,6 +31,17 @@ export const POST = (request: Request) => app.fetch(request);
31
31
  `
32
32
  );
33
33
  }
34
+ function writePluginsFile(targetDir) {
35
+ fs.writeFileSync(
36
+ path.join(targetDir, "plugins.ts"),
37
+ `import { defineJuniorPlugins } from "@sentry/junior";
38
+
39
+ export const plugins = defineJuniorPlugins([
40
+ "@sentry/junior-maintenance",
41
+ ]);
42
+ `
43
+ );
44
+ }
34
45
  function writeNitroConfig(targetDir) {
35
46
  fs.writeFileSync(
36
47
  path.join(targetDir, "nitro.config.ts"),
@@ -39,7 +50,11 @@ import { juniorNitro } from "@sentry/junior/nitro";
39
50
 
40
51
  export default defineConfig({
41
52
  preset: "vercel",
42
- modules: [juniorNitro()],
53
+ modules: [
54
+ juniorNitro({
55
+ plugins: "./plugins",
56
+ }),
57
+ ],
43
58
  routes: {
44
59
  "/**": { handler: "./server.ts" },
45
60
  },
@@ -129,6 +144,7 @@ async function runInit(dir, log = console.log) {
129
144
  },
130
145
  dependencies: {
131
146
  "@sentry/junior": "latest",
147
+ "@sentry/junior-maintenance": "latest",
132
148
  hono: "^4.12.0"
133
149
  },
134
150
  devDependencies: {
@@ -199,6 +215,7 @@ SENTRY_ORG_SLUG=
199
215
  );
200
216
  writeServerEntry(target);
201
217
  writeQueueConsumerEntry(target);
218
+ writePluginsFile(target);
202
219
  writeNitroConfig(target);
203
220
  writeViteConfig(target);
204
221
  writeVercelJson(target);
@@ -37,7 +37,10 @@ export interface DashboardRequesterIdentity {
37
37
  slackUserName?: string;
38
38
  }
39
39
  export interface DashboardSessionReport {
40
- conversationTitle?: string;
40
+ /** Always-populated display title. LLM-generated title when available, otherwise the
41
+ * Slack channel/conversation location label or a generic fallback. Privacy redaction
42
+ * wins over everything else for non-public conversations. */
43
+ displayTitle: string;
41
44
  cumulativeDurationMs: number;
42
45
  cumulativeUsage?: DashboardTurnUsage;
43
46
  conversationId: string;
@@ -48,7 +51,6 @@ export interface DashboardSessionReport {
48
51
  lastProgressAt: string;
49
52
  completedAt?: string;
50
53
  surface: DashboardSurface;
51
- title: string;
52
54
  requesterIdentity?: DashboardRequesterIdentity;
53
55
  channel?: string;
54
56
  channelName?: string;
@@ -93,6 +95,8 @@ export interface DashboardTurnReport extends DashboardSessionReport {
93
95
  }
94
96
  export interface DashboardConversationReport {
95
97
  conversationId: string;
98
+ /** Always-populated display title, computed the same way as DashboardSessionReport.displayTitle. */
99
+ displayTitle: string;
96
100
  generatedAt: string;
97
101
  turns: DashboardTurnReport[];
98
102
  }
package/dist/reporting.js CHANGED
@@ -6,10 +6,12 @@ import {
6
6
  formatSlackConversationRedactedLabel,
7
7
  getAgentPluginOperationalReports,
8
8
  getAgentTurnSessionRecord,
9
+ getConversationDetails,
10
+ getConversationDetailsForIds,
9
11
  listAgentTurnSessionSummaries,
10
12
  listAgentTurnSessionSummariesForConversation,
11
13
  resolveSlackConversationContextFromThreadId
12
- } from "./chunk-N3MORKTH.js";
14
+ } from "./chunk-HOGQL2H6.js";
13
15
  import {
14
16
  discoverSkills
15
17
  } from "./chunk-GT67ZWZQ.js";
@@ -89,12 +91,6 @@ function surfaceFromConversationId(conversationId) {
89
91
  function surfaceFromSummary(summary) {
90
92
  return summary.surface ?? surfaceFromConversationId(summary.conversationId);
91
93
  }
92
- function titleFromSummary(summary) {
93
- if (summary.state === "awaiting_resume" && summary.resumeReason) {
94
- return `Awaiting ${summary.resumeReason} resume`;
95
- }
96
- return `Turn ${summary.sessionId}`;
97
- }
98
94
  function requesterIdentityReport(requester) {
99
95
  if (!requester) return void 0;
100
96
  const identity = {
@@ -116,27 +112,33 @@ function turnUsageReport(usage) {
116
112
  };
117
113
  return Object.keys(report).length > 0 ? report : void 0;
118
114
  }
119
- function sessionReportFromSummary(summary, nowMs = Date.now()) {
115
+ function sessionReportFromSummary(summary, nowMs = Date.now(), details) {
120
116
  const slackThread = parseSlackThreadId(summary.conversationId);
121
117
  const privacy = resolveConversationPrivacy({
122
118
  conversationId: summary.conversationId
123
119
  });
120
+ const effectiveChannelName = details?.channelName ?? summary.channelName;
124
121
  const slackConversation = resolveSlackConversationContextFromThreadId({
125
122
  threadId: summary.conversationId,
126
- channelName: summary.channelName
123
+ channelName: effectiveChannelName
127
124
  });
128
125
  const privateLabel = privacy !== "public" ? slackConversation ? formatSlackConversationRedactedLabel(slackConversation) : PRIVATE_CONVERSATION_LABEL : void 0;
129
- const conversationTitle = privateLabel ?? summary.conversationTitle;
130
- const channelName = privateLabel ?? summary.channelName;
126
+ const channelName = privateLabel ?? effectiveChannelName;
127
+ const effectiveSurface = details?.originSurface ?? surfaceFromSummary(summary);
128
+ const displayTitle = privateLabel ?? details?.displayTitle ?? slackStatsLocationLabel({
129
+ channel: slackThread?.channelId,
130
+ channelName: effectiveChannelName
131
+ }) ?? surfaceFallbackLabel(effectiveSurface);
132
+ const effectiveRequester = details?.originRequester ?? summary.requester;
131
133
  const sentryConversationUrl = buildSentryConversationUrl(
132
134
  summary.conversationId
133
135
  );
134
136
  const sentryTraceUrl = summary.traceId ? buildSentryTraceUrl(summary.traceId) : void 0;
135
- const requesterIdentity = requesterIdentityReport(summary.requester);
137
+ const requesterIdentity = requesterIdentityReport(effectiveRequester);
136
138
  const cumulativeUsage = turnUsageReport(summary.cumulativeUsage);
137
139
  return {
138
140
  conversationId: summary.conversationId,
139
- ...conversationTitle ? { conversationTitle } : {},
141
+ displayTitle,
140
142
  id: summary.sessionId,
141
143
  status: statusFromCheckpoint(summary, nowMs),
142
144
  startedAt: new Date(summary.startedAtMs).toISOString(),
@@ -145,8 +147,7 @@ function sessionReportFromSummary(summary, nowMs = Date.now()) {
145
147
  ...summary.state === "completed" ? { completedAt: new Date(summary.updatedAtMs).toISOString() } : {},
146
148
  cumulativeDurationMs: summary.cumulativeDurationMs,
147
149
  ...cumulativeUsage ? { cumulativeUsage } : {},
148
- surface: surfaceFromSummary(summary),
149
- title: titleFromSummary(summary),
150
+ surface: effectiveSurface,
150
151
  ...requesterIdentity ? { requesterIdentity } : {},
151
152
  ...slackThread ? { channel: slackThread.channelId } : {},
152
153
  ...channelName ? { channelName } : {},
@@ -237,8 +238,27 @@ function slackStatsLocationLabel(input) {
237
238
  }
238
239
  return name || channelId;
239
240
  }
241
+ function surfaceFallbackLabel(surface) {
242
+ if (surface === "scheduler") return "Scheduler";
243
+ if (surface === "api") return "API";
244
+ if (surface === "internal") return "Internal";
245
+ return "Conversation";
246
+ }
247
+ function displayTitleFromDetails(conversationId, details) {
248
+ if (!details) return void 0;
249
+ const slackThread = parseSlackThreadId(conversationId);
250
+ const slackConversation = resolveSlackConversationContextFromThreadId({
251
+ threadId: conversationId,
252
+ channelName: details.channelName
253
+ });
254
+ const privateLabel = resolveConversationPrivacy({ conversationId }) !== "public" ? formatSlackConversationRedactedLabel(slackConversation) ?? PRIVATE_CONVERSATION_LABEL : void 0;
255
+ return privateLabel ?? details.displayTitle ?? slackStatsLocationLabel({
256
+ channel: slackThread?.channelId,
257
+ channelName: details.channelName
258
+ }) ?? (details.originSurface ? surfaceFallbackLabel(details.originSurface) : void 0);
259
+ }
240
260
  function locationLabel(turn) {
241
- return slackStatsLocationLabel(turn) ?? (turn.surface === "scheduler" ? "Scheduler" : turn.surface === "api" ? "API" : turn.surface === "internal" ? "Internal" : "Unknown");
261
+ return slackStatsLocationLabel(turn) ?? surfaceFallbackLabel(turn.surface);
242
262
  }
243
263
  function emptyStatsItem(label) {
244
264
  return {
@@ -265,7 +285,7 @@ function statusSignals(turns) {
265
285
  }
266
286
  function statsItems(map) {
267
287
  return [...map.values()].sort(
268
- (left, right) => right.conversations - left.conversations || right.durationMs - left.durationMs || left.label.localeCompare(right.label)
288
+ (left, right) => right.conversations - left.conversations || right.turns - left.turns || right.durationMs - left.durationMs || left.label.localeCompare(right.label)
269
289
  );
270
290
  }
271
291
  function newestTurn(turns) {
@@ -620,11 +640,18 @@ async function readSessions() {
620
640
  const summaries = await listAgentTurnSessionSummaries(
621
641
  DASHBOARD_SESSION_FEED_LIMIT
622
642
  );
643
+ const detailsByConversationId = await getConversationDetailsForIds(
644
+ summaries.map((s) => s.conversationId)
645
+ );
623
646
  return {
624
647
  source: "turn_session_records",
625
648
  generatedAt: new Date(nowMs).toISOString(),
626
649
  sessions: summaries.map(
627
- (summary) => sessionReportFromSummary(summary, nowMs)
650
+ (summary) => sessionReportFromSummary(
651
+ summary,
652
+ nowMs,
653
+ detailsByConversationId.get(summary.conversationId)
654
+ )
628
655
  )
629
656
  };
630
657
  }
@@ -643,13 +670,20 @@ async function readConversationStats() {
643
670
  summaries: sampledSummaries,
644
671
  truncated
645
672
  });
673
+ const detailsByConversationId = await getConversationDetailsForIds(
674
+ reportSummaries.map((summary) => summary.conversationId)
675
+ );
646
676
  return buildConversationStatsReport({
647
677
  generatedAt,
648
678
  nowMs,
649
679
  sampleLimit: DASHBOARD_CONVERSATION_STATS_LIMIT,
650
680
  sampleSize: sampledSummaries.length,
651
681
  sessions: reportSummaries.map(
652
- (summary) => sessionReportFromSummary(summary, nowMs)
682
+ (summary) => sessionReportFromSummary(
683
+ summary,
684
+ nowMs,
685
+ detailsByConversationId.get(summary.conversationId)
686
+ )
653
687
  ),
654
688
  truncated
655
689
  });
@@ -663,7 +697,11 @@ async function readPluginOperationalReports() {
663
697
  };
664
698
  }
665
699
  async function readConversation(conversationId) {
666
- const summaries = (await listAgentTurnSessionSummariesForConversation(conversationId)).sort(
700
+ const [rawSummaries, details] = await Promise.all([
701
+ listAgentTurnSessionSummariesForConversation(conversationId),
702
+ getConversationDetails(conversationId)
703
+ ]);
704
+ const summaries = rawSummaries.sort(
667
705
  (left, right) => left.startedAtMs - right.startedAtMs || left.updatedAtMs - right.updatedAtMs || left.sessionId.localeCompare(right.sessionId)
668
706
  );
669
707
  const turns = await Promise.all(
@@ -686,7 +724,7 @@ async function readConversation(conversationId) {
686
724
  const traceId = summary.traceId ?? sessionRecord?.traceId ?? (canExposeTranscript ? traceIdFromTranscript(transcript) : void 0);
687
725
  const sentryTraceUrl = traceId ? buildSentryTraceUrl(traceId) : void 0;
688
726
  return {
689
- ...sessionReportFromSummary(summary),
727
+ ...sessionReportFromSummary(summary, Date.now(), details),
690
728
  ...traceId ? { traceId } : {},
691
729
  ...sentryTraceUrl ? { sentryTraceUrl } : {},
692
730
  transcriptAvailable: Boolean(sessionRecord) && canExposeTranscript,
@@ -700,8 +738,11 @@ async function readConversation(conversationId) {
700
738
  };
701
739
  })
702
740
  );
741
+ const firstTurn = turns[0];
742
+ const displayTitle = firstTurn?.displayTitle ?? displayTitleFromDetails(conversationId, details) ?? surfaceFallbackLabel(firstTurn?.surface ?? "slack");
703
743
  return {
704
744
  conversationId,
745
+ displayTitle,
705
746
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
706
747
  turns
707
748
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior",
3
- "version": "0.69.0",
3
+ "version": "0.70.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -65,7 +65,7 @@
65
65
  "node-html-markdown": "^2.0.0",
66
66
  "yaml": "^2.9.0",
67
67
  "zod": "^4.4.3",
68
- "@sentry/junior-plugin-api": "0.69.0"
68
+ "@sentry/junior-plugin-api": "0.70.0"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@types/node": "^25.9.1",
@@ -78,7 +78,7 @@
78
78
  "typescript": "^6.0.3",
79
79
  "vercel": "^54.4.0",
80
80
  "vitest": "^4.1.7",
81
- "@sentry/junior-scheduler": "0.69.0"
81
+ "@sentry/junior-scheduler": "0.70.0"
82
82
  },
83
83
  "scripts": {
84
84
  "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly",