diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2a5c66c0e6e8..985dbc7333d3 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -75,7 +75,7 @@ "effect": "catalog:", "expo": "~57.0.18", "expo-asset": "~57.0.15", - "expo-audio": "~57.0.4", + "expo-audio": "57.0.4", "expo-auth-session": "~57.0.10", "expo-blur": "~57.0.2", "expo-build-properties": "~57.0.15", diff --git a/apps/mobile/src/state/thread-order.test.ts b/apps/mobile/src/state/thread-order.test.ts index 92db3a89b2f4..b30447a01907 100644 --- a/apps/mobile/src/state/thread-order.test.ts +++ b/apps/mobile/src/state/thread-order.test.ts @@ -4,6 +4,7 @@ import type { Atom } from "effect/unstable/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { createPendingThreadOrder } from "../features/threads/threadOrder"; +import { makeThreadShellFixture } from "../test-fixtures"; import { appAtomRegistry } from "./atom-registry"; import { beginPendingThreadOrder, @@ -36,17 +37,14 @@ const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable< >; function fixture() { - // Only section membership and order fields are read by this coordinator. - const rows = ["a", "b"].map( - (id, index) => - ({ - id: ThreadId.make(id), - environmentId: EnvironmentId.make("env"), - createdAt: `2026-06-01T0${2 - index}:00:00.000Z`, - archivedAt: null, - pinnedAt: null, - activeOrderKey: null, - }) as EnvironmentThreadShell, + // The shared section helper also reads lineage/settled/snooze fields, so + // partial casts break when it grows — build complete shells instead. + const rows = ["a", "b"].map((id, index) => + makeThreadShellFixture({ + id: ThreadId.make(id), + environmentId: EnvironmentId.make("env"), + createdAt: `2026-06-01T0${2 - index}:00:00.000Z`, + }), ); appAtomRegistry.set(shellsAtom, rows); const pending = createPendingThreadOrder({ diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index e800e0b9f151..600cc7727d91 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -38,6 +38,7 @@ import { ProjectionStoreV2 } from "./ProjectionStore.ts"; import * as ThreadSettlementService from "./ThreadSettlementService.ts"; import { + autoSettlementSettingsKey, isAutoSettlementCandidate, QUEUED_TURN_START_GRACE_MS, resolveAutoSettlementAt, @@ -282,6 +283,38 @@ describe("resolveAutoSettlementAt", () => { const NOW = "2026-08-28T12:00:00.000Z"; const PROJECT_ID = ProjectId.make("settlement-project"); +const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); + +describe("autoSettlementSettingsKey", () => { + it("distinguishes a project that inherits the threshold from one that disables it", () => { + const inherits = autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: true } }, + }); + const never = autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [PROJECT_ID]: { sidebarAutoSettleOnMerge: true, sidebarAutoSettleAfterDays: null }, + }, + }); + assert.notStrictEqual(inherits, never); + }); + + it("ignores project overrides that do not touch settlement", () => { + const base = autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: false } }, + }); + const unrelated = autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [LINKED_PROJECT_ID]: { defaultThreadEnvMode: "worktree" }, + [PROJECT_ID]: { sidebarAutoSettleOnMerge: false, defaultAutoPull: true }, + }, + }); + assert.strictEqual(base, unrelated); + }); +}); type AutoSettleCommand = Extract; diff --git a/knip.jsonc b/knip.jsonc index 2c7cadb56642..7c63ac1dcc34 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -24,6 +24,7 @@ "src/claudeHistoryWorker.ts!", "scripts/cli.ts", "scripts/probe-claude-fork-local-rollback-replay.ts", + "scripts/verify-background-live.ts", "src/provider/testFixtures/*.mjs", ], // Keep the transitive Effect runtime pinned for standalone npm installs. diff --git a/packages/client-runtime/src/work-log/userInput.ts b/packages/client-runtime/src/work-log/userInput.ts index 18dea7dd83c2..19e927d9dab4 100644 --- a/packages/client-runtime/src/work-log/userInput.ts +++ b/packages/client-runtime/src/work-log/userInput.ts @@ -1,9 +1,4 @@ -import { projectQuestionToolInput } from "@t3tools/shared/toolActivity"; -import { - type OrchestrationThreadActivity, - UserInputAttachmentAnswerPayload, -} from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; +import { type UserInputAttachmentAnswerPayload } from "@t3tools/contracts"; function record(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -11,151 +6,6 @@ function record(value: unknown): Record | undefined { : undefined; } -const isQuestionAnswer = Schema.is(UserInputAttachmentAnswerPayload); - -function displayOptionAnswer(value: unknown, labels: ReadonlyMap): unknown { - if (typeof value === "string") return labels.get(value) ?? value; - if (Array.isArray(value)) return value.map((answer) => displayOptionAnswer(answer, labels)); - const nested = record(value); - return nested && "answers" in nested - ? { ...nested, answers: displayOptionAnswer(nested.answers, labels) } - : value; -} - -function questionFingerprint( - turnId: string, - questions: ReadonlyArray, -): string | undefined { - const texts = questions.map((question) => (typeof question === "string" ? question.trim() : "")); - // Sort the fresh array in place because Hermes does not provide toSorted. - return texts.length > 0 && texts.every(Boolean) - ? JSON.stringify([turnId, texts.sort()]) - : undefined; -} - -function withoutDuplicateQuestionTools( - activities: ReadonlyArray, -): ReadonlyArray { - const questions = new Set(); - for (const activity of activities) { - if (activity.kind !== "user-input.answer-submitted" || !activity.turnId) continue; - const payload = record(activity.payload); - const texts = Object.values(record(payload?.questionTextById) ?? {}); - const fingerprint = questionFingerprint(activity.turnId, texts); - if (fingerprint) questions.add(fingerprint); - } - if (questions.size === 0) return activities; - const duplicateToolIds = new Set(); - for (const activity of activities) { - if (!activity.kind.startsWith("tool.") || !activity.turnId) continue; - const payload = record(activity.payload); - if (typeof payload?.toolCallId !== "string") continue; - const input = projectQuestionToolInput(record(payload.data) ?? {}, payload.title).input; - if (!input) continue; - const fingerprint = questionFingerprint( - activity.turnId, - input.questions.map((question) => record(question)?.question), - ); - if (fingerprint && questions.has(fingerprint)) { - duplicateToolIds.add(JSON.stringify([activity.turnId, payload.toolCallId])); - } - } - return activities.filter((activity) => { - const payload = record(activity.payload); - const toolCallId = payload?.toolCallId; - return ( - activity.tone === "error" || - /^(failed|declined|stopped|cancelled)$/.test(String(payload?.status)) || - !activity.kind.startsWith("tool.") || - typeof toolCallId !== "string" || - !duplicateToolIds.has(JSON.stringify([activity.turnId, toolCallId])) - ); - }); -} - -/** Keep a question and its answer at the original tool position in the work log. */ -export function foldUserInputActivities( - activities: ReadonlyArray, -): ReadonlyArray { - const requests = new Map(); - for (const activity of activities) { - if ( - activity.kind !== "user-input.requested" && - activity.kind !== "user-input.resolved" && - activity.kind !== "user-input.answer-submitted" - ) - continue; - const requestId = record(activity.payload)?.requestId; - if (typeof requestId !== "string" || !requestId) continue; - const group = requests.get(requestId) ?? []; - group.push(activity); - requests.set(requestId, group); - } - const replacements = new Map(); - for (const [requestId, group] of requests) { - const payloads = group.map((activity) => record(activity.payload)!); - const questions = new Map>(); - const texts = new Map(); - for (const payload of payloads) { - for (const [id, text] of Object.entries(record(payload.questionTextById) ?? {})) - texts.set(id, text); - for (const value of Array.isArray(payload.questions) ? payload.questions : []) { - const question = record(value); - if (typeof question?.id !== "string") continue; - questions.set(question.id, question); - if (typeof question.question === "string") texts.set(question.id, question.question); - } - } - const questionTextById = Object.fromEntries(texts); - const submitted = group.findLast( - (activity) => - activity.kind === "user-input.answer-submitted" && - record(record(activity.payload)?.answers), - ); - const rawAnswers = - record(record(submitted?.payload)?.answers) ?? - payloads.map((payload) => record(payload.answers)).findLast(Boolean) ?? - {}; - const answers = Object.fromEntries( - Object.entries(rawAnswers).map(([id, value]) => { - const options = questions.get(id)?.options; - const labels = new Map(); - for (const candidate of Array.isArray(options) ? options : []) { - const option = record(candidate); - if (typeof option?.value === "string" && typeof option.label === "string") - labels.set(option.value, option.label); - } - return [id, displayOptionAnswer(value, labels)]; - }), - ); - const attachmentsByQuestionId = Object.fromEntries( - payloads.flatMap((payload) => Object.entries(record(payload.attachmentsByQuestionId) ?? {})), - ); - const answer = { requestId, questionTextById, answers, attachmentsByQuestionId }; - if (!isQuestionAnswer(answer)) continue; - const submittedAnswer = - Object.keys(answers).length > 0 || Object.keys(attachmentsByQuestionId).length > 0; - for (const activity of group) replacements.set(activity, null); - replacements.set(group[0]!, { - ...group[0]!, - kind: "user-input.answer-submitted", - tone: "tool", - summary: submittedAnswer - ? "User input submitted" - : group.some((activity) => activity.kind === "user-input.resolved") - ? "User input dismissed" - : "User input requested", - payload: answer, - }); - } - return withoutDuplicateQuestionTools( - activities.flatMap((activity) => { - const replacement = replacements.get(activity); - return replacement === null ? [] : [replacement ?? activity]; - }), - ); -} - export function getQuestionAnswerText(value: unknown): string { if (typeof value === "string") return value; if (Array.isArray(value)) return value.map(getQuestionAnswerText).filter(Boolean).join(", "); diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 8c1c65565f51..2fd04e76696f 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -259,36 +259,3 @@ export function deriveToolActivityPresentation( summary: title ?? fallbackSummary, }; } - -export function projectQuestionToolInput(data: Record, title: unknown) { - const item = asRecord(data.item); - const toolName = data.toolName ?? data.tool ?? item?.tool ?? title; - if (typeof toolName !== "string") return {}; - const name = toolName - .split(/__|[./]/) - .at(-1) - ?.replace(/[_\s]/g, "") - .toLowerCase(); - if (!name || !/^(askuserquestion|requestuserinput(?:async)?|askquestion|question)$/.test(name)) - return {}; - const input = asRecord( - data.input ?? data.rawInput ?? asRecord(data.state)?.input ?? item?.arguments, - ); - const questions = input?.questions ?? asRecord(input?.params)?.questions; - if (!Array.isArray(questions)) return {}; - // Clients match native tools to the canonical question; choices and answers - // already live on the user-input activities and need not cross the wire twice. - return { - toolName, - input: { - questions: questions.map((value) => { - const question = asRecord(value); - return { - question: asTrimmedString( - question?.question ?? question?.question_text ?? question?.prompt ?? question?.title, - ), - }; - }), - }, - }; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d99ffbb6ceb..5d7f35a1cd86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,7 +322,7 @@ importers: specifier: ~57.0.15 version: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2) expo-audio: - specifier: ~57.0.4 + specifier: 57.0.4 version: 57.0.4(patch_hash=fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a)(expo-asset@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-auth-session: specifier: ~57.0.10