Skip to content

Commit bef2865

Browse files
authored
Merge branch 'main' into fix/special-char-passwords
2 parents b81cf62 + 478d893 commit bef2865

File tree

7 files changed

+387
-34
lines changed

7 files changed

+387
-34
lines changed

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,15 +644,35 @@ export namespace Telemetry {
644644
session_id: string
645645
/** skipped = no cache or stale, passed = valid SQL, blocked = invalid SQL caught, error = validation itself failed */
646646
outcome: "skipped" | "passed" | "blocked" | "error"
647-
/** why: no_cache, stale_cache, empty_cache, valid, non_structural, structural_error, validation_exception */
647+
/** why: no_cache, stale_cache, empty_cache, valid, non_structural, structural_error, dispatcher_failed, validation_exception */
648648
reason: string
649+
/** warehouse driver type (postgres, snowflake, bigquery, ...) — enables per-warehouse catch-rate analysis */
650+
warehouse_type: string
651+
/** read / write / unknown — enables per-query-type analysis */
652+
query_type: string
653+
/** SHA-256 prefix of masked SQL — join key to sql_execute_failure events for same query */
654+
masked_sql_hash: string
649655
schema_columns: number
650656
/** true when schema scan hit the column-scan cap — flags samples biased by large-warehouse truncation */
651657
schema_truncated: boolean
652658
duration_ms: number
653659
error_message?: string
654660
}
655661
// altimate_change end
662+
// altimate_change start — plan-agent model tool-call refusal detection
663+
| {
664+
type: "plan_no_tool_generation"
665+
timestamp: number
666+
session_id: string
667+
message_id: string
668+
model_id: string
669+
provider_id: string
670+
/** "stop" finish_reason without any tool calls in the session — flags models that refuse to tool-call in plan mode */
671+
finish_reason: string
672+
/** output tokens on the stop-without-tools generation — helps distinguish "refused" (low) from "wrote a long text plan" (high) */
673+
tokens_output: number
674+
}
675+
// altimate_change end
656676

657677
/** SHA256 hash a masked error message for anonymous grouping. */
658678
export function hashError(maskedMessage: string): string {

packages/opencode/src/altimate/tools/sql-execute.ts

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export const SqlExecuteTool = Tool.define("sql_execute", {
4343
// but does NOT block execution. Used to measure catch rate before deciding
4444
// whether to enable blocking in a future release. Fire-and-forget so it
4545
// doesn't add latency to the sql_execute hot path.
46-
preValidateSql(args.query, args.warehouse).catch(() => {})
46+
preValidateSql(args.query, args.warehouse, queryType).catch(() => {})
4747
// altimate_change end
4848

4949
try {
@@ -115,22 +115,39 @@ interface PreValidationResult {
115115
error?: string
116116
}
117117

118-
async function preValidateSql(sql: string, warehouse?: string): Promise<PreValidationResult> {
118+
async function preValidateSql(sql: string, warehouse: string | undefined, queryType: string): Promise<PreValidationResult> {
119119
const startTime = Date.now()
120+
// Yield the event loop before heavy synchronous SQLite work so concurrent
121+
// tasks aren't blocked. Bun's sqlite API is sync and listColumns can touch
122+
// hundreds of thousands of rows for large warehouses.
123+
await new Promise<void>((resolve) => setImmediate(resolve))
124+
125+
// Precompute correlation fields used in every telemetry event this function emits.
126+
const maskedSqlHash = Telemetry.hashError(Telemetry.maskString(sql))
127+
120128
try {
121129
// Resolve the warehouse the same way sql.execute's fallback path does:
122130
// when caller omits `warehouse`, sql.execute uses Registry.list()[0].
123131
// Matching that here keeps the shadow validation aligned with actual
124132
// execution (dbt-routed queries are a known gap — they short-circuit
125133
// before this fallback, so validation may use a different warehouse
126134
// than the one dbt selects).
135+
const registered = Registry.list().warehouses
127136
let warehouseName = warehouse
128137
if (!warehouseName) {
129-
const registered = Registry.list().warehouses
130138
warehouseName = registered[0]?.name
131139
}
140+
const warehouseInfo = registered.find((w) => w.name === warehouseName)
141+
const warehouseType = warehouseInfo?.type ?? "unknown"
142+
143+
const ctx: TrackCtx = {
144+
warehouse_type: warehouseType,
145+
query_type: queryType,
146+
masked_sql_hash: maskedSqlHash,
147+
}
148+
132149
if (!warehouseName) {
133-
trackPreValidation("skipped", "no_cache", 0, Date.now() - startTime, false)
150+
trackPreValidation("skipped", "no_cache", 0, Date.now() - startTime, false, ctx)
134151
return { blocked: false }
135152
}
136153

@@ -139,31 +156,39 @@ async function preValidateSql(sql: string, warehouse?: string): Promise<PreValid
139156

140157
const warehouseStatus = status.warehouses.find((w) => w.name === warehouseName)
141158
if (!warehouseStatus?.last_indexed) {
142-
trackPreValidation("skipped", "no_cache", 0, Date.now() - startTime, false)
159+
trackPreValidation("skipped", "no_cache", 0, Date.now() - startTime, false, ctx)
143160
return { blocked: false }
144161
}
145162

146163
// Check cache freshness
147164
const cacheAge = Date.now() - new Date(warehouseStatus.last_indexed).getTime()
148165
if (cacheAge > CACHE_TTL_MS) {
149-
trackPreValidation("skipped", "stale_cache", 0, Date.now() - startTime, false)
166+
trackPreValidation("skipped", "stale_cache", 0, Date.now() - startTime, false, ctx)
150167
return { blocked: false }
151168
}
152169

153170
// Build schema context from cached columns
154171
const columns = cache.listColumns(warehouseName, COLUMN_SCAN_LIMIT)
155172
const schemaTruncated = columns.length >= COLUMN_SCAN_LIMIT
156173
if (columns.length === 0) {
157-
trackPreValidation("skipped", "empty_cache", 0, Date.now() - startTime, false)
174+
trackPreValidation("skipped", "empty_cache", 0, Date.now() - startTime, false, ctx)
158175
return { blocked: false }
159176
}
160177

161-
const schemaContext: Record<string, any> = {}
178+
// Build schema context keyed by fully-qualified name (database.schema.table)
179+
// so multi-database warehouses don't collide on schema+table alone.
180+
// Dedupe columns per table to defend against residual collisions.
181+
const schemaContext: Record<string, { name: string; type: string; nullable: boolean }[]> = {}
182+
const seenColumns: Record<string, Set<string>> = {}
162183
for (const col of columns) {
163-
const tableName = col.schema_name ? `${col.schema_name}.${col.table}` : col.table
184+
const tableName = [col.database, col.schema_name, col.table].filter(Boolean).join(".")
185+
if (!tableName) continue
164186
if (!schemaContext[tableName]) {
165187
schemaContext[tableName] = []
188+
seenColumns[tableName] = new Set()
166189
}
190+
if (seenColumns[tableName].has(col.name)) continue
191+
seenColumns[tableName].add(col.name)
167192
schemaContext[tableName].push({
168193
name: col.name,
169194
type: col.data_type || "VARCHAR",
@@ -178,60 +203,61 @@ async function preValidateSql(sql: string, warehouse?: string): Promise<PreValid
178203
schema_context: schemaContext,
179204
})
180205

206+
// If the dispatcher itself failed, don't treat missing data as "valid".
207+
if (!validationResult.success) {
208+
const errMsg = typeof validationResult.error === "string" ? validationResult.error : undefined
209+
trackPreValidation("error", "dispatcher_failed", 0, Date.now() - startTime, false, ctx, errMsg)
210+
return { blocked: false }
211+
}
212+
181213
const data = (validationResult.data ?? {}) as Record<string, any>
182214
const errors = Array.isArray(data.errors) ? data.errors : []
183215
const isValid = data.valid !== false && errors.length === 0
184216

185217
if (isValid) {
186-
trackPreValidation("passed", "valid", columns.length, Date.now() - startTime, schemaTruncated)
218+
trackPreValidation("passed", "valid", columns.length, Date.now() - startTime, schemaTruncated, ctx)
187219
return { blocked: false }
188220
}
189221

190222
// Only block on high-confidence structural errors
191223
const structuralErrors = errors.filter((e: any) => {
192224
const msg = (e.message ?? "").toLowerCase()
193-
return msg.includes("column") || msg.includes("table") || msg.includes("not found") || msg.includes("does not exist")
225+
return /\b(column|table|view|relation|identifier|not found|does not exist)\b/.test(msg)
194226
})
195227

196228
if (structuralErrors.length === 0) {
197229
// Non-structural errors (ambiguous cases) — let them through
198-
trackPreValidation("passed", "non_structural", columns.length, Date.now() - startTime, schemaTruncated)
230+
trackPreValidation("passed", "non_structural", columns.length, Date.now() - startTime, schemaTruncated, ctx)
199231
return { blocked: false }
200232
}
201233

202-
// Build helpful error with available columns
203234
const errorMsgs = structuralErrors.map((e: any) => e.message).join("\n")
204-
const referencedTables = Object.keys(schemaContext).slice(0, 10)
205-
const availableColumns = referencedTables
206-
.map((t) => `${t}: ${schemaContext[t].map((c: any) => c.name).join(", ")}`)
207-
.join("\n")
208-
209-
const errorOutput = [
210-
`Pre-execution validation failed (validated against cached schema):`,
211-
``,
212-
errorMsgs,
213-
``,
214-
`Available tables and columns:`,
215-
availableColumns,
216-
``,
217-
`Fix the query and retry. If the schema cache is outdated, run schema_index to refresh it.`,
218-
].join("\n")
219-
220-
trackPreValidation("blocked", "structural_error", columns.length, Date.now() - startTime, schemaTruncated, errorMsgs)
221-
return { blocked: true, error: errorOutput }
235+
trackPreValidation("blocked", "structural_error", columns.length, Date.now() - startTime, schemaTruncated, ctx, errorMsgs)
236+
// Shadow mode: caller discards the result. When blocking is enabled in the
237+
// future, build errorOutput here with the structural errors and
238+
// schemaContext keys for user-facing guidance.
239+
return { blocked: false }
222240
} catch {
223241
// Validation failure should never block execution
224-
trackPreValidation("error", "validation_exception", 0, Date.now() - startTime, false)
242+
const ctx: TrackCtx = { warehouse_type: "unknown", query_type: queryType, masked_sql_hash: maskedSqlHash }
243+
trackPreValidation("error", "validation_exception", 0, Date.now() - startTime, false, ctx)
225244
return { blocked: false }
226245
}
227246
}
228247

248+
interface TrackCtx {
249+
warehouse_type: string
250+
query_type: string
251+
masked_sql_hash: string
252+
}
253+
229254
function trackPreValidation(
230255
outcome: "skipped" | "passed" | "blocked" | "error",
231256
reason: string,
232257
schema_columns: number,
233258
duration_ms: number,
234259
schema_truncated: boolean,
260+
ctx: TrackCtx,
235261
error_message?: string,
236262
) {
237263
// Mask schema identifiers (table / column names, paths, user IDs) from the
@@ -244,6 +270,9 @@ function trackPreValidation(
244270
session_id: Telemetry.getContext().sessionId,
245271
outcome,
246272
reason,
273+
warehouse_type: ctx.warehouse_type,
274+
query_type: ctx.query_type,
275+
masked_sql_hash: ctx.masked_sql_hash,
247276
schema_columns,
248277
schema_truncated,
249278
duration_ms,

packages/opencode/src/session/processor.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export namespace SessionProcessor {
4949
// altimate_change start — per-step generation telemetry
5050
let stepStartTime = Date.now()
5151
// altimate_change end
52+
// altimate_change start — plan-agent tool-call-refusal detection
53+
// Some models (observed: qwen3-coder-next, occasionally gpt-5.4) end plan-agent
54+
// steps with finish_reason=stop and never emit tool calls. User abandons the
55+
// session thinking it's stuck. Track whether the session has ever produced a
56+
// tool call; if plan agent finishes its first step with stop-no-tools, warn.
57+
let sessionToolCallsMade = 0
58+
let planNoToolWarningEmitted = false
59+
// altimate_change end
5260

5361
const result = {
5462
get message() {
@@ -162,6 +170,9 @@ export namespace SessionProcessor {
162170
metadata: value.providerMetadata,
163171
})
164172
toolcalls[value.toolCallId] = part as MessageV2.ToolPart
173+
// altimate_change start — session has now tool-called; suppresses plan refusal warning
174+
sessionToolCallsMade++
175+
// altimate_change end
165176

166177
const parts = await MessageV2.parts(input.assistantMessage.id)
167178
const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD)
@@ -322,6 +333,52 @@ export namespace SessionProcessor {
322333
...(usage.tokens.cache.write > 0 && { tokens_cache_write: usage.tokens.cache.write }),
323334
})
324335
// altimate_change end
336+
// altimate_change start — detect plan-agent tool-call refusal
337+
// A plan-agent step that ends with finish=stop and NO tool calls
338+
// (ever) in the session means the model wrote text and gave up.
339+
// Users read the text, see no progress, and abandon. Surface a
340+
// warning + telemetry so the pattern is measurable and the user
341+
// knows to try a different model.
342+
if (
343+
input.assistantMessage.agent === "plan" &&
344+
value.finishReason === "stop" &&
345+
sessionToolCallsMade === 0 &&
346+
!planNoToolWarningEmitted
347+
) {
348+
planNoToolWarningEmitted = true
349+
Telemetry.track({
350+
type: "plan_no_tool_generation",
351+
timestamp: Date.now(),
352+
session_id: input.sessionID,
353+
message_id: input.assistantMessage.id,
354+
model_id: input.model.id,
355+
provider_id: input.model.providerID,
356+
finish_reason: value.finishReason,
357+
tokens_output: usage.tokens.output,
358+
})
359+
log.warn("plan agent stopped without tool calls — model may not be tool-calling properly", {
360+
sessionID: input.sessionID,
361+
modelID: input.model.id,
362+
providerID: input.model.providerID,
363+
tokensOutput: usage.tokens.output,
364+
})
365+
// synthetic: true so this warning is shown in the TUI but
366+
// excluded when the transcript is replayed to the LLM next turn
367+
// (prompt.ts filters synthetic text parts — see lines 648, 795).
368+
await Session.updatePart({
369+
id: PartID.ascending(),
370+
messageID: input.assistantMessage.id,
371+
sessionID: input.assistantMessage.sessionID,
372+
type: "text",
373+
synthetic: true,
374+
text:
375+
`⚠️ altimate-code: the \`plan\` agent is running on \`${input.model.providerID}/${input.model.id}\`, ` +
376+
`which returned text without calling any tools. If you expected the plan agent to explore the ` +
377+
`codebase, try switching to a model with stronger tool-use via \`/model\`.`,
378+
time: { start: Date.now(), end: Date.now() },
379+
})
380+
}
381+
// altimate_change end
325382
await Session.updatePart({
326383
id: PartID.ascending(),
327384
reason: value.finishReason,

packages/opencode/test/altimate/connections.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,36 @@ describe("ConnectionRegistry", () => {
5151
)
5252
})
5353

54+
test("cassandra gives helpful hint instead of generic unsupported error", async () => {
55+
Registry.setConfigs({
56+
mydb: { type: "cassandra", host: "localhost" },
57+
})
58+
await expect(Registry.get("mydb")).rejects.toThrow("not yet supported")
59+
await expect(Registry.get("mydb")).rejects.toThrow("cqlsh")
60+
})
61+
62+
test("cockroachdb suggests using postgres type", async () => {
63+
Registry.setConfigs({
64+
mydb: { type: "cockroachdb", host: "localhost" },
65+
})
66+
await expect(Registry.get("mydb")).rejects.toThrow("postgres")
67+
})
68+
69+
test("timescaledb suggests using postgres type", async () => {
70+
Registry.setConfigs({
71+
mydb: { type: "timescaledb", host: "localhost" },
72+
})
73+
await expect(Registry.get("mydb")).rejects.toThrow("postgres")
74+
})
75+
76+
test("truly unknown type gives generic unsupported error with supported list", async () => {
77+
Registry.setConfigs({
78+
mydb: { type: "neo4j", host: "localhost" },
79+
})
80+
await expect(Registry.get("mydb")).rejects.toThrow("Unsupported database type")
81+
await expect(Registry.get("mydb")).rejects.toThrow("Supported:")
82+
})
83+
5484
test("getConfig returns config for known connection", () => {
5585
Registry.setConfigs({
5686
mydb: { type: "postgres", host: "localhost" },
@@ -608,6 +638,44 @@ trino_project:
608638
fs.rmSync(tmpDir, { recursive: true })
609639
}
610640
})
641+
642+
test("clickhouse adapter maps correctly from dbt profiles", async () => {
643+
const fs = await import("fs")
644+
const os = await import("os")
645+
const path = await import("path")
646+
647+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dbt-test-"))
648+
const profilesPath = path.join(tmpDir, "profiles.yml")
649+
650+
fs.writeFileSync(
651+
profilesPath,
652+
`
653+
ch_project:
654+
outputs:
655+
dev:
656+
type: clickhouse
657+
host: clickhouse.example.com
658+
port: 8443
659+
user: default
660+
password: secret
661+
database: analytics
662+
schema: default
663+
`,
664+
)
665+
666+
try {
667+
const connections = await parseDbtProfiles(profilesPath)
668+
expect(connections).toHaveLength(1)
669+
expect(connections[0].type).toBe("clickhouse")
670+
expect(connections[0].config.type).toBe("clickhouse")
671+
expect(connections[0].config.host).toBe("clickhouse.example.com")
672+
expect(connections[0].config.port).toBe(8443)
673+
expect(connections[0].config.user).toBe("default")
674+
expect(connections[0].config.database).toBe("analytics")
675+
} finally {
676+
fs.rmSync(tmpDir, { recursive: true })
677+
}
678+
})
611679
})
612680

613681
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)