diff --git a/apps/extension/src/capture.ts b/apps/extension/src/capture.ts
index da26f4597a9..7e9e66b2609 100644
--- a/apps/extension/src/capture.ts
+++ b/apps/extension/src/capture.ts
@@ -9,6 +9,8 @@ import { extractPageText } from '@/extract'
import { digestUrl } from '@/utils'
/** A public capture error whose message is safe to share with the hosted composer. */
+export const PAGE_CHANGED_MESSAGE = 'The page changed. Attach it again.'
+
export class PageCaptureError extends Error {}
/** Chrome scripting failures may contain the full private URL of the tab. */
@@ -49,7 +51,7 @@ export async function captureActivePage(
if (tab?.id === undefined || tab.incognito)
throw new PageCaptureError('This page cannot be attached.')
if (expected && expected.tabId !== tab.id) {
- throw new PageCaptureError('The page changed. Attach it again.')
+ throw new PageCaptureError(PAGE_CHANGED_MESSAGE)
}
if (!tab.url) {
const allowed = await chrome.permissions.contains({ origins: PAGE_ACCESS_ORIGINS })
@@ -75,10 +77,10 @@ export async function captureActivePage(
signal?.addEventListener('abort', removeListeners, { once: true })
try {
const snapshot = await readTab({ tabId: tab.id, url: tab.url }, kind, expected, signal)
- if (changed) throw new PageCaptureError('The page changed. Attach it again.')
+ if (changed) throw new PageCaptureError(PAGE_CHANGED_MESSAGE)
return snapshot
} catch (error) {
- if (changed) throw new PageCaptureError('The page changed. Attach it again.')
+ if (changed) throw new PageCaptureError(PAGE_CHANGED_MESSAGE)
if (expected && !(error instanceof PageCaptureError))
throw new PageCaptureError('The original page is unavailable. Attach the current page.')
throw error
@@ -95,7 +97,7 @@ async function readTab(
) {
const url = new URL(tab.url)
if (expected && (await digestUrl(tab.url)) !== expected.urlDigest) {
- throw new PageCaptureError('The page changed. Attach it again.')
+ throw new PageCaptureError(PAGE_CHANGED_MESSAGE)
}
let pathname = url.pathname
if (url.origin === SIM_ORIGIN) {
@@ -132,12 +134,12 @@ async function readTab(
world: 'ISOLATED',
})
if (identity[0]?.documentId !== result.documentId || identity[0]?.result !== tab.url) {
- throw new PageCaptureError('The page changed. Attach it again.')
+ throw new PageCaptureError(PAGE_CHANGED_MESSAGE)
}
const [current] = await chrome.tabs.query({ active: true, currentWindow: true })
signal?.throwIfAborted()
if (current?.id !== tab.tabId || current.url !== tab.url || current.status === 'loading') {
- throw new PageCaptureError('The page changed. Attach it again.')
+ throw new PageCaptureError(PAGE_CHANGED_MESSAGE)
}
if (result.result.error) {
if (kind === 'selection')
diff --git a/apps/extension/src/reference.ts b/apps/extension/src/reference.ts
index 46e0f717a25..2b3890c9370 100644
--- a/apps/extension/src/reference.ts
+++ b/apps/extension/src/reference.ts
@@ -11,6 +11,8 @@ import {
} from '@/capture'
import { REFERENCE_PAGE_KEY } from '@/config'
+export const PAGE_ATTACHMENT_PENDING_MESSAGE = 'Wait for the page attachment, then try again.'
+
interface ReferenceOptions {
session: () => string | null
post: (message: ExtensionToAppMessage) => void
@@ -124,7 +126,7 @@ export function createReferenceCapture({
replyError(
sessionId,
requestId,
- manualError?.message ?? 'Wait for the page attachment, then try again.',
+ manualError?.message ?? PAGE_ATTACHMENT_PENDING_MESSAGE,
manualError?.reason
)
return
diff --git a/apps/extension/src/sidepanel.tsx b/apps/extension/src/sidepanel.tsx
index 73beab91033..7e579300244 100644
--- a/apps/extension/src/sidepanel.tsx
+++ b/apps/extension/src/sidepanel.tsx
@@ -8,17 +8,19 @@ import { createRoot } from 'react-dom/client'
import {
captureActivePage,
captureErrorMessage,
+ PAGE_CHANGED_MESSAGE,
PageCaptureError,
SiteAccessRequiredError,
UnreadableSelectionError,
} from '@/capture'
import { CAPTURE_INTENT_KEY, CAPTURE_INTENT_TTL_MS, REFERENCE_PAGE_KEY, SIM_ORIGIN } from '@/config'
import { type CaptureIntent, captureIntentSchema } from '@/intent'
-import { createReferenceCapture } from '@/reference'
+import { createReferenceCapture, PAGE_ATTACHMENT_PENDING_MESSAGE } from '@/reference'
import { SearchRecoveryDialog } from '@/search-recovery-dialog'
import { PageAccessButton, SiteAccessDialog } from '@/site-access'
import '@/shell.css'
+const PAGE_ATTACHMENT_EXPIRED_MESSAGE = 'The page attachment expired. Attach it again.'
const BLOCKED_SELECTION =
'Select readable text again, attach the page explicitly, or turn off Reference current page.'
const KEYBOARD_FOCUS_HINT = navigator.platform.startsWith('Mac')
@@ -49,9 +51,7 @@ function SidePanel({ origin }: SidePanelProps) {
if (!(status instanceof SiteAccessRequiredError)) return
const navigated = (tabId: number, change: chrome.tabs.OnUpdatedInfo) => {
if (tabId === status.tabId && (change.status === 'loading' || change.url !== undefined)) {
- setStatus((current) =>
- current === status ? 'The page changed. Attach it again.' : current
- )
+ setStatus((current) => (current === status ? PAGE_CHANGED_MESSAGE : current))
}
}
const activated = (tab: chrome.tabs.OnActivatedInfo) => {
@@ -74,7 +74,7 @@ function SidePanel({ origin }: SidePanelProps) {
if (Date.now() - Date.parse(snapshot.capturedAt) > CAPTURE_INTENT_TTL_MS) {
pendingRef.current = null
focusRequested.current = false
- setStatus('The page attachment expired. Attach it again.')
+ setStatus(PAGE_ATTACHMENT_EXPIRED_MESSAGE)
return
}
const message: ExtensionToAppMessage = {
@@ -130,12 +130,12 @@ function SidePanel({ origin }: SidePanelProps) {
}
) {
if (referenceRef.current?.busy()) {
- setStatus('Wait for the page attachment, then try again.')
+ setStatus(PAGE_ATTACHMENT_PENDING_MESSAGE)
return
}
if (busyRef.current) return
if (pendingRef.current) {
- setStatus('Wait for the page attachment, then try again.')
+ setStatus(PAGE_ATTACHMENT_PENDING_MESSAGE)
return
}
busyRef.current = true
@@ -171,7 +171,7 @@ function SidePanel({ origin }: SidePanelProps) {
manualCaptureError: () => {
if (busyRef.current || pendingRef.current || processingManual || manualIntents.size)
return {
- message: 'Wait for the page attachment, then try again.',
+ message: PAGE_ATTACHMENT_PENDING_MESSAGE,
}
if (unreadableSelection.current) return { message: BLOCKED_SELECTION, reason: 'selection' }
},
@@ -249,7 +249,7 @@ function SidePanel({ origin }: SidePanelProps) {
}
if (disposed || !intent) return
if (!currentIntent(intent, window.id)) {
- setStatus('The page attachment expired. Attach it again.')
+ setStatus(PAGE_ATTACHMENT_EXPIRED_MESSAGE)
return
}
if (intent.kind === 'focus') {
@@ -362,7 +362,7 @@ function SidePanel({ origin }: SidePanelProps) {
) {
pendingRef.current = null
focusRequested.current = false
- setStatus('The page attachment expired. Attach it again.')
+ setStatus(PAGE_ATTACHMENT_EXPIRED_MESSAGE)
}
void consumeIntent()
}, 5_000)
diff --git a/apps/extension/src/site-access.tsx b/apps/extension/src/site-access.tsx
index ddf6468eaef..0ee685ade23 100644
--- a/apps/extension/src/site-access.tsx
+++ b/apps/extension/src/site-access.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useState } from 'react'
import { Chip, ChipModalBody, ChipModalHeader, ChipModalSurface } from '@sim/emcn'
import { PAGE_ACCESS_ORIGINS } from '@/config'
@@ -8,25 +8,17 @@ interface PageAccessButtonProps {
}
export function PageAccessButton({ allowed, onAccessGranted }: PageAccessButtonProps) {
- const mounted = useRef(false)
const [status, setStatus] = useState<'idle' | 'requesting' | 'error'>('idle')
- useEffect(() => {
- mounted.current = true
- return () => {
- mounted.current = false
- }
- }, [])
-
async function enable() {
if (status === 'requesting') return
setStatus('requesting')
try {
const granted = await chrome.permissions.request({ origins: PAGE_ACCESS_ORIGINS })
- if (mounted.current) setStatus('idle')
+ setStatus('idle')
if (granted) onAccessGranted()
} catch {
- if (mounted.current) setStatus('error')
+ setStatus('error')
}
}
@@ -47,7 +39,11 @@ export function PageAccessButton({ allowed, onAccessGranted }: PageAccessButtonP
void enable()}>
Allow across websites
- {status === 'error' && Could not enable page access. Try again.}
+ {status === 'error' && (
+
+ Could not enable page access. Try again.
+
+ )}
)
}
diff --git a/apps/sim/app/extension/[workspaceId]/workspace.tsx b/apps/sim/app/extension/[workspaceId]/workspace.tsx
index 1bd55ea6ed3..6c5f61f72cb 100644
--- a/apps/sim/app/extension/[workspaceId]/workspace.tsx
+++ b/apps/sim/app/extension/[workspaceId]/workspace.tsx
@@ -4,8 +4,7 @@ import { Chip, ChipLink } from '@sim/emcn'
import { isApiClientError } from '@/lib/api/client/errors'
import { ExtensionChat } from '@/app/extension/[workspaceId]/chat'
import ExtensionLoading from '@/app/extension/loading'
-import { useExtensionIdentity } from '@/app/extension/providers'
-import { ExtensionSignIn } from '@/app/extension/sign-in'
+import { ExtensionSessionExpired } from '@/app/extension/providers'
import { ChatPlanModal } from '@/app/workspace/[workspaceId]/home/components/chat-plan-modal'
import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader'
import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader'
@@ -26,13 +25,11 @@ interface ExtensionWorkspaceProps {
}
export function ExtensionWorkspace({ workspaceId }: ExtensionWorkspaceProps) {
- const { userId } = useExtensionIdentity()
const { data, isPending, error, refetch } = useWorkspaceHostContextQuery(workspaceId, {
refetchInterval: WORKSPACE_HOST_CONTEXT_STALE_TIME,
refetchOnWindowFocus: true,
})
- if (isApiClientError(error) && error.status === 401)
- return
+ if (isApiClientError(error) && error.status === 401) return
if (isPending) return
const accessDenied = isApiClientError(error) && (error.status === 403 || error.status === 404)
if (accessDenied || !data) {
diff --git a/apps/sim/app/extension/o/[organizationId]/organization.tsx b/apps/sim/app/extension/o/[organizationId]/organization.tsx
index fb5dd1b01c0..eaad05cbccc 100644
--- a/apps/sim/app/extension/o/[organizationId]/organization.tsx
+++ b/apps/sim/app/extension/o/[organizationId]/organization.tsx
@@ -10,8 +10,7 @@ import { ExtensionHeader } from '@/app/extension/[workspaceId]/header'
import { extensionChatParam } from '@/app/extension/[workspaceId]/search-params'
import { useExtensionComposer } from '@/app/extension/hooks/use-composer'
import ExtensionLoading from '@/app/extension/loading'
-import { useExtensionIdentity } from '@/app/extension/providers'
-import { ExtensionSignIn } from '@/app/extension/sign-in'
+import { ExtensionSessionExpired } from '@/app/extension/providers'
import { ExtensionUnavailable } from '@/app/extension/unavailable'
import { OrganizationConversation } from '@/app/o/[organizationId]/components/organization-conversation'
import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider'
@@ -27,14 +26,12 @@ interface ExtensionOrganizationProps {
}
export function ExtensionOrganization({ context }: ExtensionOrganizationProps) {
- const { userId } = useExtensionIdentity()
const access = useOrganizationSummary(context.organization.id)
const history = useOrganizationMothershipChats(context.organization.id)
const denied = [access.error, history.error].find(
(error) => isApiClientError(error) && [401, 403, 404].includes(error.status)
)
- if (isApiClientError(denied) && denied.status === 401)
- return
+ if (isApiClientError(denied) && denied.status === 401) return
if (access.isPending) return
if (denied || !access.data?.chatAccess)
return (
diff --git a/apps/sim/app/extension/workspaces.tsx b/apps/sim/app/extension/workspaces.tsx
index 74f5d607252..e3ed7a67bf2 100644
--- a/apps/sim/app/extension/workspaces.tsx
+++ b/apps/sim/app/extension/workspaces.tsx
@@ -11,17 +11,14 @@ import {
} from '@sim/emcn'
import { isApiClientError } from '@/lib/api/client/errors'
import ExtensionLoading from '@/app/extension/loading'
-import { useExtensionIdentity } from '@/app/extension/providers'
-import { ExtensionSignIn } from '@/app/extension/sign-in'
+import { ExtensionSessionExpired } from '@/app/extension/providers'
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
export function ExtensionWorkspaces() {
- const { userId } = useExtensionIdentity()
const [scrollElement, setScrollElement] = useState(null)
const scrollEdges = useScrollEdges(scrollElement)
const { data: workspaces, isPending, error, refetch } = useWorkspacesQuery()
- if (isApiClientError(error) && error.status === 401)
- return
+ if (isApiClientError(error) && error.status === 401) return
if (isPending) return
const transientError =
!isApiClientError(error) || error.status === 408 || error.status === 429 || error.status >= 500
diff --git a/apps/sim/hooks/use-speech-to-text.ts b/apps/sim/hooks/use-speech-to-text.ts
index fc7b5a14576..5a5d2e60c8b 100644
--- a/apps/sim/hooks/use-speech-to-text.ts
+++ b/apps/sim/hooks/use-speech-to-text.ts
@@ -233,7 +233,7 @@ export function useSpeechToText({
: { workspaceId: workspaceIdRef.current },
})
} catch (err) {
- if (!mountedRef.current || generation !== generationRef.current) return false
+ if (generation !== generationRef.current) return false
if (isApiClientError(err) && err.status === 402) {
const isMemberLimit = (err.body as { scope?: string } | null)?.scope === 'member'
onUsageLimitExceededRef.current?.(err.message, isMemberLimit)
@@ -244,7 +244,7 @@ export function useSpeechToText({
const token = typeof tokenData.token === 'string' ? tokenData.token : undefined
if (!token) throw new Error('Failed to get speech token')
- if (!mountedRef.current || generation !== generationRef.current) return false
+ if (generation !== generationRef.current) return false
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
@@ -256,7 +256,7 @@ export function useSpeechToText({
},
})
- if (!mountedRef.current || generation !== generationRef.current) {
+ if (generation !== generationRef.current) {
stream.getTracks().forEach((track) => track.stop())
return false
}
@@ -281,7 +281,7 @@ export function useSpeechToText({
ws.onerror = () => reject(new Error('WebSocket connection failed'))
ws.onmessage = (event) => {
- if (!mountedRef.current || generation !== generationRef.current) return
+ if (generation !== generationRef.current) return
try {
const msg = JSON.parse(event.data)
@@ -325,7 +325,7 @@ export function useSpeechToText({
}
})
- if (!mountedRef.current || generation !== generationRef.current) {
+ if (generation !== generationRef.current) {
ws.close()
stream.getTracks().forEach((track) => track.stop())
return false
diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.test.ts b/apps/sim/lib/logs/execution/trace-secret-projection.test.ts
index dc82652bd25..bd339ed6f7a 100644
--- a/apps/sim/lib/logs/execution/trace-secret-projection.test.ts
+++ b/apps/sim/lib/logs/execution/trace-secret-projection.test.ts
@@ -1176,24 +1176,86 @@ describe('projectTraceSpansForSecrets whole-trace content budget', () => {
expect(projected[1].toolCalls).toHaveLength(50_000)
})
- it('withholds the whole top-level span whose descendant crossed the budget', async () => {
+ it('keeps checked content under a single workflow root when a descendant crosses the budget', async () => {
const projected = await projectTraceSpansForSecrets(
[
- heavySpan('span-a', 'first'),
createSpan({
- id: 'span-b',
- output: { marker: 'parent top-secret' },
- children: [heavySpan('child-1', 'child one'), heavySpan('child-2', 'child two')],
+ id: 'workflow-execution',
+ output: { marker: 'root top-secret' },
+ children: [
+ heavySpan('block-a', 'first top-secret'),
+ createSpan({
+ id: 'loop',
+ output: { marker: 'loop top-secret' },
+ children: [
+ heavySpan('iteration-1', 'second'),
+ heavySpan('iteration-2', 'third top-secret', {
+ children: [createSpan({ id: 'nested', output: { marker: 'nested top-secret' } })],
+ }),
+ createSpan({ id: 'iteration-3', output: { marker: 'fourth top-secret' } }),
+ ],
+ }),
+ createSpan({ id: 'block-b', output: { marker: 'fifth top-secret' } }),
+ ],
}),
+ createSpan({ id: 'after-root', output: { marker: 'sixth top-secret' } }),
],
{ registry: SECRET_REGISTRY, store: STORE, allowLargeValueWrites: false }
)
- expect(projected[0].output?.marker).toBe('first')
- expect(projected[1]).not.toHaveProperty('output')
- expect(projected[1].contentWithheld).toBe(true)
- expect(projected[1].children?.map((child) => child.id)).toEqual(['child-1', 'child-2'])
- expect(projected[1].children?.every((child) => !Object.hasOwn(child, 'output'))).toBe(true)
+ const [root, afterRoot] = projected
+ const [blockA, loop, blockB] = root.children ?? []
+ const [iteration1, iteration2, iteration3] = loop.children ?? []
+
+ for (const [kept, marker] of [
+ [root, 'root {{API_SECRET}}'],
+ [blockA, 'first {{API_SECRET}}'],
+ [loop, 'loop {{API_SECRET}}'],
+ [iteration1, 'second'],
+ ] as const) {
+ expect(kept.output?.marker).toBe(marker)
+ expect(kept).not.toHaveProperty('contentWithheld')
+ }
+ for (const withheld of [iteration2, iteration2.children?.[0], iteration3, blockB, afterRoot]) {
+ expect(withheld).toMatchObject({ contentWithheld: true })
+ expect(withheld).not.toHaveProperty('output')
+ }
expect(JSON.stringify(projected)).not.toContain('top-secret')
+ expect(warnMock).toHaveBeenCalledWith(
+ 'Trace content exceeded the projection budget; withholding the remaining content',
+ expect.objectContaining({ retainedSpanCount: 4, withheldSpanCount: 5 })
+ )
+ })
+
+ it('withholds every span when a descendant fails for any reason but the budget', async () => {
+ const projected = await projectTraceSpansForSecrets(
+ [
+ createSpan({
+ id: 'workflow-execution',
+ output: { marker: 'root' },
+ children: [
+ createSpan({ id: 'block-a', output: { marker: 'first' } }),
+ createSpan({
+ id: 'block-b',
+ toolCalls: Array.from({ length: 100_001 }, (_, index) => ({
+ name: `tool-${index}`,
+ duration: 0,
+ startTime: '2026-07-01T00:00:00.000Z',
+ endTime: '2026-07-01T00:00:00.000Z',
+ status: 'success',
+ })),
+ }),
+ ],
+ }),
+ ],
+ { registry: SECRET_REGISTRY, store: STORE, allowLargeValueWrites: false }
+ )
+
+ expect(projected[0]).not.toHaveProperty('output')
+ expect(projected[0].children?.[0]).not.toHaveProperty('output')
+ expect(warnMock).toHaveBeenCalledWith(
+ 'Trace secret projection failed; retaining structural spans only',
+ expect.objectContaining({ executionId: 'execution-1', phase: 'read' })
+ )
})
})
diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.ts b/apps/sim/lib/logs/execution/trace-secret-projection.ts
index 0e0d7f1c733..2060479f2cd 100644
--- a/apps/sim/lib/logs/execution/trace-secret-projection.ts
+++ b/apps/sim/lib/logs/execution/trace-secret-projection.ts
@@ -86,6 +86,8 @@ interface ProjectionContext {
omittedContentFields: number
logDetails: ProjectionLogDetails
displayFieldsBySpanId?: ReadonlyMap
+ /** Set once the content walk runs out of budget; every span after that keeps only its structure. */
+ contentBudgetFailure?: TraceContentBudgetError
}
interface OversizedHydrationGate {
@@ -1046,16 +1048,52 @@ async function sanitizeTraceSpan(
if (span.children !== undefined) {
assertTraceStructureArrayFits(span.children, state)
- const children: TraceSpan[] = []
- for (const child of span.children) {
- children.push(await sanitizeTraceSpan(child, context, depth + 1, state, assertContentIsSafe))
- }
- projected.children = children
+ projected.children = await sanitizeSiblingSpans(
+ span.children,
+ context,
+ depth + 1,
+ state,
+ assertContentIsSafe
+ )
}
return projected
}
+/**
+ * A budget failure stops at the span that crossed it, so the spans checked before it keep their
+ * content at any depth, ancestors included: each span's own content passed the whole-trace check
+ * before its children were walked. The span that crossed and every span after it fall back to
+ * structure, which resumes the budget where the retained spans left it.
+ */
+async function sanitizeSiblingSpans(
+ spans: readonly TraceSpan[],
+ context: ProjectionContext,
+ depth: number,
+ state: BoundedTraceStructureState,
+ assertContentIsSafe: SpanContentAssertion
+): Promise {
+ const projected: TraceSpan[] = []
+ let index = 0
+ for (; index < spans.length && !context.contentBudgetFailure; index += 1) {
+ const retainedStructureNodes = state.nodes
+ try {
+ projected.push(
+ await sanitizeTraceSpan(spans[index], context, depth, state, assertContentIsSafe)
+ )
+ } catch (error) {
+ if (!(error instanceof TraceContentBudgetError)) throw error
+ context.contentBudgetFailure = error
+ state.nodes = retainedStructureNodes
+ break
+ }
+ }
+ const withheld = projectBoundedArray(spans.slice(index), (span) =>
+ structuralOnlySpan(span, state, depth)
+ )
+ return projected.concat(withheld)
+}
+
function stripLegacyToolCallContent(call: ToolCall): ToolCall {
return omit(call, ['input', 'output', 'error']) as ToolCall
}
@@ -1279,9 +1317,9 @@ function projectBoundedTraceSpans(
state: BoundedTraceStructureState,
depth: number
) => TraceSpan | undefined,
- logDetails: ProjectionLogDetails,
- state: BoundedTraceStructureState = { nodes: 0, truncated: false }
+ logDetails: ProjectionLogDetails
): TraceSpan[] {
+ const state: BoundedTraceStructureState = { nodes: 0, truncated: false }
const projected = projectBoundedArray(traceSpans, (span) => project(span, state, 0))
if (state.truncated) {
logger.warn(
@@ -1292,14 +1330,12 @@ function projectBoundedTraceSpans(
return projected
}
-/** Pass `state` to continue a structure budget that earlier spans of the same trace already spent. */
function structuralOnlyTraceSpans(
traceSpans: TraceSpan[],
- logDetails: ProjectionLogDetails,
- state?: BoundedTraceStructureState
+ logDetails: ProjectionLogDetails
): TraceSpan[] {
try {
- return projectBoundedTraceSpans(traceSpans, structuralOnlySpan, logDetails, state)
+ return projectBoundedTraceSpans(traceSpans, structuralOnlySpan, logDetails)
} catch (error) {
logger.warn('Trace structure could not be safely traversed; omitting projected spans', {
...logDetails,
@@ -1643,28 +1679,30 @@ async function assertPostTransformTraceSpansAreSafe(
await verifyPostTransformLargeValues(refs, context, new Set())
}
-/**
- * Counts the real spans kept before `withheldFrom` and withheld from it on, naming the withheld
- * display-envelope fields apart from them.
- */
+/** Counts the real spans that kept or lost content, naming the withheld display-envelope fields apart. */
function describeWithheldContent(
- traceSpans: readonly TraceSpan[],
- withheldFrom: number,
+ projected: readonly TraceSpan[],
displayFieldsBySpanId: ReadonlyMap | undefined
): { retainedSpanCount: number; withheldSpanCount: number; withheldDisplayFields?: string[] } {
- let retainedDisplayFieldCount = 0
+ let retainedSpanCount = 0
+ let withheldSpanCount = 0
const withheldDisplayFields: string[] = []
- if (displayFieldsBySpanId?.size) {
- for (let index = 0; index < traceSpans.length; index += 1) {
- const displayField = displayFieldsBySpanId.get(traceSpans[index].id)
- if (displayField === undefined) continue
- if (index < withheldFrom) retainedDisplayFieldCount += 1
- else withheldDisplayFields.push(displayField)
+ const visit = (spans: readonly TraceSpan[]) => {
+ for (const span of spans) {
+ const displayField = displayFieldsBySpanId?.get(span.id)
+ if (displayField === undefined) {
+ if (span.contentWithheld) withheldSpanCount += 1
+ else retainedSpanCount += 1
+ } else if (span.contentWithheld) {
+ withheldDisplayFields.push(displayField)
+ }
+ if (span.children) visit(span.children)
}
}
+ visit(projected)
return {
- retainedSpanCount: withheldFrom - retainedDisplayFieldCount,
- withheldSpanCount: traceSpans.length - withheldFrom - withheldDisplayFields.length,
+ retainedSpanCount,
+ withheldSpanCount,
...(withheldDisplayFields.length > 0 ? { withheldDisplayFields } : {}),
}
}
@@ -1732,40 +1770,30 @@ export async function projectTraceSpansForSecrets(
options.displayFieldsBySpanId
)
- const projected: TraceSpan[] = []
const state: BoundedTraceStructureState = { nodes: 0, truncated: false }
assertTraceStructureArrayFits(traceSpans, state)
- const assertContentIsSafe = createSpanContentAssertion(traceSpans, context)
- for (let index = 0; index < traceSpans.length; index += 1) {
- const retainedStructureNodes = state.nodes
- try {
- projected.push(
- await sanitizeTraceSpan(traceSpans[index], context, 0, state, assertContentIsSafe)
- )
- } catch (error) {
- if (!(error instanceof TraceContentBudgetError)) throw error
- /**
- * Every span already returned was sanitized and then passed the whole-trace check,
- * descendants included, so only the span that crossed the budget and those after it
- * lose their content. The fallback resumes the structure budget where the retained
- * spans left it, so the trace keeps exactly the structure a whole-trace fallback would.
- */
- logger.warn(
- 'Trace content exceeded the projection budget; withholding the remaining content',
- {
- ...logDetails,
- ...describeWithheldContent(traceSpans, index, options.displayFieldsBySpanId),
- failure: describeProjectionFailure(error),
- }
- )
- return [
- ...projected,
- ...structuralOnlyTraceSpans(traceSpans.slice(index), logDetails, {
- nodes: retainedStructureNodes,
- truncated: false,
- }),
- ]
- }
+ const projected = await sanitizeSiblingSpans(
+ traceSpans,
+ context,
+ 0,
+ state,
+ createSpanContentAssertion(traceSpans, context)
+ )
+ if (context.contentBudgetFailure) {
+ logger.warn(
+ 'Trace content exceeded the projection budget; withholding the remaining content',
+ {
+ ...logDetails,
+ ...describeWithheldContent(projected, options.displayFieldsBySpanId),
+ failure: describeProjectionFailure(context.contentBudgetFailure),
+ }
+ )
+ }
+ if (state.truncated) {
+ logger.warn(
+ 'Trace structure exceeded projection limits; truncating projected structure',
+ logDetails
+ )
}
return projected
} catch (error) {
diff --git a/apps/sim/lib/mothership/chat/page-context.ts b/apps/sim/lib/mothership/chat/page-context.ts
index be88ee20e6a..78c8b827645 100644
--- a/apps/sim/lib/mothership/chat/page-context.ts
+++ b/apps/sim/lib/mothership/chat/page-context.ts
@@ -1,9 +1,9 @@
import type { PageSnapshot } from '@sim/browser-extension-protocol'
+import { utf8ByteLength } from '@sim/utils/paste'
import { MAX_CHAT_CONTEXTS } from '@/lib/mothership/chat/context-limits'
const MAX_PAGE_CONTEXT_TEXT_LENGTH = 150_000
const MAX_PAGE_CONTEXT_BYTES = 450_000
-const encoder = new TextEncoder()
/** Bounds the combined page budget and refuses ambiguous snapshot identities within a turn. */
export function getPageContextBudgetError(
@@ -19,7 +19,7 @@ export function getPageContextBudgetError(
if (ids.has(context.snapshot.id)) return 'Page snapshot IDs must be unique within a message'
ids.add(context.snapshot.id)
textLength += context.snapshot.text.length
- bytes += encoder.encode(JSON.stringify(context.snapshot)).byteLength
+ bytes += utf8ByteLength(JSON.stringify(context.snapshot))
if (textLength > MAX_PAGE_CONTEXT_TEXT_LENGTH || bytes > MAX_PAGE_CONTEXT_BYTES)
return 'These pages contain too much text'
}
diff --git a/apps/sim/lib/sim-links/application/use-cases.ts b/apps/sim/lib/sim-links/application/use-cases.ts
index f2a722a2f73..dccbdf2e47e 100644
--- a/apps/sim/lib/sim-links/application/use-cases.ts
+++ b/apps/sim/lib/sim-links/application/use-cases.ts
@@ -194,14 +194,6 @@ export const listSimLinks = defineAuthorizedOrganizationUseCase({
},
})
-async function findLinkIdByName(organizationId: string, typedName: string) {
- const link = await repository.findSimLinkDestination(
- organizationId,
- stripSimLinkPrefix(typedName)
- )
- return link?.id
-}
-
/**
* One link by id or by name as typed. Hidden exactly as browsing hides it: an unlisted link reads
* as missing to anyone who cannot manage it.
@@ -215,7 +207,14 @@ export const getSimLinkView = defineAuthorizedOrganizationUseCase({
}: OrganizationUseCaseContext) {
const viewer = viewerOf(context)
const linkId =
- 'linkId' in input ? input.linkId : await findLinkIdByName(input.organizationId, input.name)
+ 'linkId' in input
+ ? input.linkId
+ : (
+ await repository.findSimLinkDestination(
+ input.organizationId,
+ stripSimLinkPrefix(input.name)
+ )
+ )?.id
const record = linkId ? await repository.getSimLink(db, input.organizationId, linkId) : null
if (!record || (record.unlisted && !canManage(record.createdBy?.id ?? null, viewer)))
throw new OrchestrationError('not_found', 'Link not found')
diff --git a/apps/sim/scripts/test-browser-extension-e2e.ts b/apps/sim/scripts/test-browser-extension-e2e.ts
index 64996fd55e8..a82c9a4dc29 100644
--- a/apps/sim/scripts/test-browser-extension-e2e.ts
+++ b/apps/sim/scripts/test-browser-extension-e2e.ts
@@ -1502,6 +1502,7 @@ try {
await mic.click()
await expect(stop).toBeVisible()
await expect.poll(() => sessions.length).toBe(2)
+ assert.equal(sessions[0].closed, false, 'Stale-transcript grace must still be open')
sessions[0].socket.send(
JSON.stringify({ message_type: 'partial_transcript', text: 'stale thought' })
)
diff --git a/packages/browser-extension-protocol/src/index.ts b/packages/browser-extension-protocol/src/index.ts
index 4532a879758..06a9828905f 100644
--- a/packages/browser-extension-protocol/src/index.ts
+++ b/packages/browser-extension-protocol/src/index.ts
@@ -127,7 +127,6 @@ export const SIM_LINK_URL_MAX_LENGTH = 2048
/** Short hosts a link opens from: `sim/` and `go/`. */
export const SIM_LINK_HOSTS = ['sim', 'go'] as const
-/** {@link SIM_LINK_HOSTS} as a regular-expression alternation. */
export const SIM_LINK_HOST_PATTERN = SIM_LINK_HOSTS.join('|')
/** Where the extension asks for the address-bar suggestions behind its `sim` keyword. */