Skip to content

Commit 6843e2d

Browse files
Haiderclaude
andcommitted
fix(onboarding): consensus review fixes for #1049
Addresses the findings from the review that fall inside this PR. MAJOR — markSetupComplete() ran before the model-availability check (dialog-provider). The branch below it deliberately refuses to claim success when the gateway connects but offers nothing usable, and said so in a comment — but completion had already been marked, so telemetry reported a finished onboarding while the user was told to go pick a model. Now marked where the model is actually set. This matters more since the scan gate moved onto the same signal. MAJOR — the flushTimer leak. doInit() assigned a new interval without clearing the previous handle, and shutdown() only ever clears the current one, so a second doInit() stranded a timer for the life of the process. doShutdown also nulled initPromise unconditionally, discarding a doInit() that init() had chained onto the in-flight shutdown. Both fixed; see the note in doShutdown on why only the first is covered by a test. MAJOR — instance_connected and onboarding_abandoned could both be reported for one launch. The gateway success events are emitted on the worker thread while abandonment state is main-thread-owned, so a user who finished in the browser and quit before the TUI observed the new provider was reported as abandoning at gateway_auth. The exit path now checks whether credentials landed. MAJOR — environment_scan_completed fired on every project_scan, including /discover and any model-initiated call, so a funnel query could exceed 100% conversion. Now guarded on isOnboardingSession. MINOR — command.execute.before created a tracking record for every slash command in every session, churning the capped map and evicting genuine onboarding sessions, after which their remaining activation events were silently dropped. noteCommandSubmission now only touches sessions already tracked, and /onboard-connect marks the session before flagging its own submission. MINOR — the cross-package event parity test that two comments claimed but which did not exist. Now a compile-time assertion pinning the packages/tui event union to the Telemetry variants; verified by renaming a property and watching the build fail. Required exporting the context subpath from packages/tui. MINOR — a raw HOME path could reach LLM-visible tool output on a sample-setup failure. Paths are masked in `output`; the full message stays in metadata, which the model never sees. MINOR — documented why sample_setup_completed uses a success boolean instead of a _failed sibling event. NIT — launchId() no longer writes process.env. The worker receives the id explicitly through WorkerOptions.env, so the write only leaked it into every subprocess the CLI spawns. Also removes the stray `// scratch` line at the end of cli/cmd/tui.ts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SLUQF3xgZHsGZHSjxe7vb
1 parent be7b5db commit 6843e2d

9 files changed

Lines changed: 160 additions & 31 deletions

File tree

packages/opencode/src/altimate/plugin/onboarding-telemetry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,17 @@ function isJobCompletion(tool: string, output: { metadata?: unknown }): boolean
8181
export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise<Hooks> {
8282
return {
8383
"command.execute.before": async (input) => {
84+
// Mark the session BEFORE flagging the submission: noteCommandSubmission only touches
85+
// sessions already tracked (so ordinary slash commands cannot churn the capped map), and
86+
// /onboard-connect is the command that creates the record in the first place.
87+
if (input.command === ONBOARD_CONNECT) OnboardingTelemetry.markOnboardingSession(input.sessionID)
88+
8489
// Any slash command means the next user message was not typed by the user — needed so
8590
// `first_prompt_sent` measures a real first prompt rather than the scan gate's hidden
8691
// `/onboard-connect` submission.
8792
OnboardingTelemetry.noteCommandSubmission(input.sessionID)
8893

8994
if (input.command !== ONBOARD_CONNECT) return
90-
OnboardingTelemetry.markOnboardingSession(input.sessionID)
9195

9296
// `skip` renders the menu immediately, with no scan to wait for, so the variant is known
9397
// now. The `scan` branch cannot be resolved here — the menu follows the scan, and the

packages/opencode/src/altimate/telemetry/index.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,11 @@ export namespace Telemetry {
850850
job: "sample_duck_db" | "breaks_downstream" | "sql_review" | "cost" | "something_else"
851851
}
852852
| {
853+
/** Deliberately a `success` boolean rather than a `sample_setup_failed` sibling, unlike
854+
* the gateway_auth_completed/_failed pair. The gateway has genuinely distinct failure
855+
* modes worth their own enum (timeout / denied / error); this tool either materialised
856+
* the sample or did not, and the useful breakdown is `reused`, which only exists on the
857+
* success path. Splitting it would duplicate the counts an analyst has to add back up. */
853858
type: "sample_setup_completed"
854859
timestamp: number
855860
session_id: string
@@ -1366,12 +1371,15 @@ export namespace Telemetry {
13661371
// so the worker computes a different start time than the main thread.
13671372
const LAUNCH_ID_ENV = "ALTIMATE_LAUNCH_ID"
13681373

1374+
let cachedLaunchId: string | undefined
1375+
13691376
export function launchId(): string {
1370-
const existing = process.env[LAUNCH_ID_ENV]
1371-
if (existing) return existing
1372-
const created = randomUUID()
1373-
process.env[LAUNCH_ID_ENV] = created
1374-
return created
1377+
// The worker reads the value the TUI handed it through WorkerOptions.env; the main thread
1378+
// generates it. Cached in module scope rather than written back to process.env — the worker
1379+
// is given it explicitly, so writing it would only leak the id into every subprocess the CLI
1380+
// spawns, for no benefit.
1381+
if (!cachedLaunchId) cachedLaunchId = process.env[LAUNCH_ID_ENV] || randomUUID()
1382+
return cachedLaunchId
13751383
}
13761384
// altimate_change end
13771385

@@ -1537,6 +1545,11 @@ export namespace Telemetry {
15371545
}
15381546
enabled = true
15391547
log.info("telemetry initialized", { mode: "appinsights" })
1548+
// altimate_change — clear any existing interval before installing a new one. doInit() can
1549+
// run more than once per process (init/shutdown cycles per session in prompt.ts), and
1550+
// without this each extra run strands the previous timer: shutdown() only ever clears the
1551+
// current handle, so orphans accumulate for the life of a `serve` process.
1552+
if (flushTimer) clearInterval(flushTimer)
15401553
const timer = setInterval(flush, FLUSH_INTERVAL_MS)
15411554
if (typeof timer === "object" && timer && "unref" in timer) (timer as any).unref()
15421555
flushTimer = timer
@@ -1671,6 +1684,7 @@ export namespace Telemetry {
16711684
}
16721685

16731686
async function doShutdown(timeoutMs?: number) {
1687+
const initPromiseAtShutdown = initPromise
16741688
// Wait for init to complete so we know whether telemetry is enabled
16751689
// and have a valid endpoint to flush to. init() is fire-and-forget
16761690
// in CLI middleware, so it may still be in-flight when shutdown runs.
@@ -1693,8 +1707,19 @@ export namespace Telemetry {
16931707
sessionId = ""
16941708
projectId = ""
16951709
machineId = ""
1696-
initPromise = undefined
1697-
initDone = false
1710+
// altimate_change — only clear initPromise if it is still the one this shutdown began with.
1711+
// init() can set `initPromise = shutdownPromise.then(doInit)`; nulling that unconditionally
1712+
// discards a doInit() which has not run yet, so the next init() starts a second one.
1713+
//
1714+
// Not covered by a test: that assignment requires initPromise to be undefined while
1715+
// shutdownPromise is still live, and those two are cleared one statement apart — a window I
1716+
// could not reach deterministically. Kept because it is free and obviously correct; the
1717+
// clear-before-assign in doInit() is what actually prevents an orphaned interval, whatever
1718+
// path leads to a second doInit.
1719+
if (initPromise === initPromiseAtShutdown) {
1720+
initPromise = undefined
1721+
initDone = false
1722+
}
16981723
}
16991724
// altimate_change end
17001725
}

packages/opencode/src/altimate/telemetry/onboarding.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K>
7070

7171
type EmitInput = DistributiveOmit<OnboardingEventInput, "timestamp" | "session_id">
7272

73+
/** Exported so the cross-package parity assertion in the tests can pin the TUI union to this. */
74+
export type OnboardingEmitInput = EmitInput
75+
7376
// Module-global, per thread. Resets on every process launch, which is correct: a fresh launch
7477
// is a fresh onboarding attempt.
7578
let furthestStage: OnboardingStage | undefined
@@ -154,8 +157,18 @@ export function isCompleted() {
154157
* Call on the exit path, before the final flush. No-ops when the user never started (a
155158
* returning user with credentials), already completed, or when it has already fired.
156159
*/
157-
export async function emitAbandonedIfIncomplete(): Promise<void> {
160+
export async function emitAbandonedIfIncomplete(opts?: { connected?: boolean }): Promise<void> {
158161
if (completed || abandonedEmitted || !furthestStage) return
162+
// A gateway sign-in that succeeded is not an abandonment, even if the TUI never observed it.
163+
// `gateway_auth_completed` and `instance_connected` are emitted on the WORKER thread, and this
164+
// state lives on the main thread — so a user who finishes in the browser and quits before the
165+
// TUI notices the new provider would otherwise be reported as abandoning at `gateway_auth`, in
166+
// the same launch that already reported a successful connection. Two contradictory terminal
167+
// states for one run, and gateway auth is the slowest step so it is the likeliest to hit this.
168+
//
169+
// The caller passes whether credentials now exist, which is the main thread's own view of the
170+
// same fact and needs no cross-thread channel.
171+
if (opts?.connected) return
159172
abandonedEmitted = true
160173
await emit({ type: "onboarding_abandoned", last_stage: furthestStage })
161174
}
@@ -220,9 +233,20 @@ function claim(sessionID: string, key: "menuShown" | "jobSelected" | "jobComplet
220233
return true
221234
}
222235

223-
/** Record that the next user message in this session comes from a slash command. */
236+
/**
237+
* Record that the next user message in this session comes from a slash command.
238+
*
239+
* Only touches sessions already tracked. `command.execute.before` fires for EVERY slash command
240+
* in every session, so creating a record here made ordinary `/discover`, `/model` and so on churn
241+
* the capped map and evict genuine onboarding sessions — after which `isOnboardingSession()`
242+
* returns false and the rest of that user's activation events are silently dropped.
243+
*
244+
* An untracked session has no onboarding state to protect, so skipping it loses nothing:
245+
* `first_prompt_sent` is gated on `isOnboardingSession` anyway.
246+
*/
224247
export function noteCommandSubmission(sessionID: string) {
225-
record(sessionID).commandSubmission = true
248+
const entry = sessions.get(sessionID)
249+
if (entry) entry.commandSubmission = true
226250
}
227251

228252
/** True (once) if this session's pending user message was command-submitted. */

packages/opencode/src/altimate/tools/project-scan.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -945,18 +945,25 @@ export const ProjectScanTool = Tool.define("project_scan", {
945945
// otherwise be recorded as having none.
946946
//
947947
// degraded[] is a sorted list of short detection-failure keys — no paths, hosts, or messages.
948-
void OnboardingTelemetry.emit({
949-
type: "environment_scan_completed",
950-
has_dbt: dbtProject.found,
951-
has_warehouse: totalConnections > 0,
952-
is_repo: git.isRepo,
953-
connections_found: totalConnections,
954-
degraded: degradedList,
955-
// Explicit session: emit() otherwise falls back to the process-global telemetry context,
956-
// which is set per prompt loop. Two concurrent sessions in `serve` or in the TUI worker
957-
// overwrite each other's context, so a scan would be attributed to whichever session most
958-
// recently started a turn.
959-
}, ctx.sessionID)
948+
// Only for onboarding runs. project_scan is also reachable via /discover and any
949+
// model-initiated call; without this guard an event in the onboarding taxonomy fires for all
950+
// of them, and a funnel query on scan_gate_shown → environment_scan_completed can exceed 100%.
951+
//
952+
// Explicit session id as well: emit() otherwise falls back to the process-global telemetry
953+
// context, which is set per prompt loop, so two concurrent sessions overwrite each other's.
954+
if (OnboardingTelemetry.isOnboardingSession(ctx.sessionID)) {
955+
void OnboardingTelemetry.emit(
956+
{
957+
type: "environment_scan_completed",
958+
has_dbt: dbtProject.found,
959+
has_warehouse: totalConnections > 0,
960+
is_repo: git.isRepo,
961+
connections_found: totalConnections,
962+
degraded: degradedList,
963+
},
964+
ctx.sessionID,
965+
)
966+
}
960967
// altimate_change end
961968

962969
// Build metadata

packages/opencode/src/altimate/tools/sample-setup.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,11 @@ export const SampleSetupTool = Tool.define("sample_setup", {
205205
return {
206206
title: "Starter materialization failed",
207207
metadata: { success: false, error: message, targetPath: "", reused: false, suffix: 0, note: "" },
208-
output: `status: error\nreason: materialize_failed\n\n${message}`,
208+
// Paths are masked in `output` but kept verbatim in `metadata.error`. `output` is what
209+
// the model sees (session/message-v2.ts), so it lands in conversation context and is sent
210+
// to the provider on every later turn — and rejectUnsafeHome messages embed HOME
211+
// verbatim. metadata never reaches the model, so the full text stays available locally.
212+
output: `status: error\nreason: materialize_failed\n\n${redactPaths(message)}`,
209213
}
210214
}
211215
},
@@ -229,6 +233,18 @@ export const SampleSetupTool = Tool.define("sample_setup", {
229233
//
230234
// Best-effort by construction: telemetry must never fail a sample setup, so any fs error
231235
// yields 0 rather than propagating.
236+
/**
237+
* Replace absolute filesystem paths with a placeholder.
238+
*
239+
* Deliberately local rather than reusing Telemetry.maskString: importing the telemetry module
240+
* here drags in Config/Account and hangs this tool's tests. This only needs to handle paths.
241+
*/
242+
function redactPaths(message: string): string {
243+
return message
244+
.replace(/(?:[A-Za-z]:)?[\\/](?:[\w.\-~ ]+[\\/])+[\w.\-~ ]*/g, "<path>")
245+
.replace(/~[\\/][^\s'"]*/g, "<path>")
246+
}
247+
232248
function countFilesWithExtension(dir: string, extension: string): number {
233249
let total = 0
234250
let entries: fs.Dirent[]

packages/opencode/src/cli/cmd/tui.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
1919
// altimate_change start — onboarding telemetry: main-thread flush on the TUI exit path
2020
import { Telemetry } from "@/altimate/telemetry"
2121
import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding"
22+
import { AltimateApi } from "@/altimate/api/client"
2223
// altimate_change end
2324

2425
declare global {
@@ -268,7 +269,12 @@ export const TuiThreadCommand = cmd({
268269
// flush() would otherwise block for REQUEST_TIMEOUT_MS (10s) on a blackholed network — a
269270
// visible hang between the user quitting and the shell prompt returning.
270271
try {
271-
await OnboardingTelemetry.emitAbandonedIfIncomplete()
272+
// Ask whether gateway credentials landed. The success events are emitted on the worker
273+
// thread and this state is main-thread-owned, so without this a browser sign-in that
274+
// completed just before the user quit is reported as an abandonment in the same launch
275+
// that already reported instance_connected.
276+
const connected = await AltimateApi.isConfigured().catch(() => false)
277+
await OnboardingTelemetry.emitAbandonedIfIncomplete({ connected })
272278
await Telemetry.shutdown({ timeoutMs: 2000 })
273279
} catch {
274280
// Never let telemetry delay or break exit.
@@ -278,4 +284,3 @@ export const TuiThreadCommand = cmd({
278284
process.exit(0)
279285
},
280286
})
281-
// scratch

packages/opencode/test/altimate/telemetry/onboarding.test.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { describe, expect, test, beforeEach, afterEach, spyOn, mock } from "bun:
1111
import { Telemetry } from "@/altimate/telemetry"
1212
import * as Onboarding from "@/altimate/telemetry/onboarding"
1313
import { OnboardingTelemetryPlugin } from "@/altimate/plugin/onboarding-telemetry"
14+
import type { OnboardingTelemetryEvent } from "@opencode-ai/tui/context/onboarding-telemetry"
1415

1516
type Tracked = Telemetry.Event
1617

@@ -268,28 +269,45 @@ describe("activation events", () => {
268269
// Slash-command suppression, which first_prompt_sent depends on
269270
// ---------------------------------------------------------------------------
270271
describe("command submission tracking", () => {
271-
test("a command-submitted message is flagged, once", () => {
272+
test("a command-submitted message in an onboarding session is flagged, once", () => {
273+
Onboarding.markOnboardingSession("ses_a")
272274
Onboarding.noteCommandSubmission("ses_a")
273275
expect(Onboarding.consumeCommandSubmission("ses_a")).toBe(true)
274276
expect(Onboarding.consumeCommandSubmission("ses_a")).toBe(false)
275277
})
276278

277279
test("a session that never ran a command is not flagged", () => {
280+
Onboarding.markOnboardingSession("ses_never")
278281
expect(Onboarding.consumeCommandSubmission("ses_never")).toBe(false)
279282
})
280283

281284
test("the flag does not leak between sessions", () => {
285+
Onboarding.markOnboardingSession("ses_a")
286+
Onboarding.markOnboardingSession("ses_b")
282287
Onboarding.noteCommandSubmission("ses_a")
283288
expect(Onboarding.consumeCommandSubmission("ses_b")).toBe(false)
284289
expect(Onboarding.consumeCommandSubmission("ses_a")).toBe(true)
285290
})
286291

287-
test("every slash command is flagged, not just /onboard-connect", async () => {
292+
test("an untracked session is not created just by running a slash command", () => {
293+
// The anti-churn property. command.execute.before fires for every slash command in every
294+
// session; if that created a record, ordinary /discover and /model traffic in a long-lived
295+
// `serve` process would evict genuine onboarding sessions from the capped map and silently
296+
// drop those users' remaining activation events.
297+
Onboarding.noteCommandSubmission("ses_unrelated")
298+
expect(Onboarding.consumeCommandSubmission("ses_unrelated")).toBe(false)
299+
expect(Onboarding.isOnboardingSession("ses_unrelated")).toBe(false)
300+
})
301+
302+
test("/onboard-connect marks the session before flagging its own submission", async () => {
303+
// Ordering matters: the command that creates the record must be flagged too, or the hidden
304+
// scan-gate submission counts as the user's first typed prompt.
288305
const hooks = await OnboardingTelemetryPlugin({} as any)
289306
await hooks["command.execute.before"]!(
290-
{ command: "discover", sessionID: "ses_c", arguments: "" },
307+
{ command: "onboard-connect", sessionID: "ses_c", arguments: "skip" },
291308
{ parts: [] } as any,
292309
)
310+
expect(Onboarding.isOnboardingSession("ses_c")).toBe(true)
293311
expect(Onboarding.consumeCommandSubmission("ses_c")).toBe(true)
294312
})
295313
})
@@ -343,3 +361,26 @@ describe("launch correlation id", () => {
343361
}
344362
})
345363
})
364+
365+
// ---------------------------------------------------------------------------
366+
// Cross-package event parity
367+
// ---------------------------------------------------------------------------
368+
//
369+
// packages/tui cannot import the Telemetry event union, so it declares its own mirror and the
370+
// host remaps `name` → `type` through an unchecked cast in cli/cmd/tui.ts. Without this, a rename
371+
// or an added property on either side compiles clean and ships malformed events.
372+
//
373+
// Compile-time only: tsgo runs over test files, so drift is a build failure. There is nothing to
374+
// assert at runtime — a type is not a value.
375+
type TuiEventAsEmitInput<E> = E extends { name: infer N } ? Omit<E, "name"> & { type: N } : never
376+
377+
// Fails to compile if any TUI event lacks a matching Telemetry variant, or if their property
378+
// names or enum values diverge.
379+
const _tuiEventsMatchTelemetry: Onboarding.OnboardingEmitInput = null as unknown as TuiEventAsEmitInput<OnboardingTelemetryEvent>
380+
void _tuiEventsMatchTelemetry
381+
382+
describe("cross-package event parity", () => {
383+
test("is enforced by the type assertion above, not at runtime", () => {
384+
expect(true).toBe(true)
385+
})
386+
})

packages/tui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"./context/epilogue": "./src/context/epilogue.tsx",
1818
"./context/exit": "./src/context/exit.tsx",
1919
"./context/kv": "./src/context/kv.tsx",
20+
"./context/onboarding-telemetry": "./src/context/onboarding-telemetry.tsx",
2021
"./context/project": "./src/context/project.tsx",
2122
"./context/runtime": "./src/context/runtime.tsx",
2223
"./context/sdk": "./src/context/sdk.tsx",

packages/tui/src/component/dialog-provider.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,10 @@ function AutoMethod(props: AutoMethodProps) {
398398
await sdk.client.instance.dispose()
399399
await sync.bootstrap()
400400
if (disposed) return
401-
// altimate_change start — mark setup complete (flips useReady → unlocks first-run chat/tips)
402-
markSetupComplete()
401+
// altimate_change start — setup is marked complete once a model is actually selected, not
402+
// here. The branch below deliberately refuses to claim success when the gateway connects but
403+
// offers nothing usable ("Connected, but no model is available yet"); marking completion up
404+
// front contradicted that, and it drives onboarding_completed and the Part 2 scan gate.
403405
// The gateway sign-in already shows the auth URL + "Waiting for authorization…".
404406
// On success, confirm inline (green) and auto-close after a moment rather than
405407
// opening the model picker. Auto-select a model so the user can chat right away.
@@ -419,6 +421,9 @@ function AutoMethod(props: AutoMethodProps) {
419421
return
420422
}
421423
local.model.set({ providerID: props.providerID, modelID: model }, { recent: true })
424+
// A model is chosen — this is the real end of setup (flips useReady → unlocks first-run
425+
// chat/tips, and opens the Part 2 scan gate).
426+
markSetupComplete()
422427
setConnected(true)
423428
closeTimer = setTimeout(() => {
424429
if (!disposed) dialog.clear()
@@ -427,6 +432,7 @@ function AutoMethod(props: AutoMethodProps) {
427432
}
428433
// altimate_change end
429434
toast.show({ message: `Connected to ${props.title}`, variant: "success" })
435+
// No markSetupComplete() here: this opens the model picker, which marks it on selection.
430436
dialog.replace(() => <DialogModel providerID={props.providerID} />)
431437
})
432438

0 commit comments

Comments
 (0)