Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 9 additions & 11 deletions apps/mobile/src/state/thread-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/orchestration-v2/ThreadSettlementService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { ProjectionStoreV2 } from "./ProjectionStore.ts";
import * as ThreadSettlementService from "./ThreadSettlementService.ts";

import {
autoSettlementSettingsKey,
isAutoSettlementCandidate,
QUEUED_TURN_START_GRACE_MS,
resolveAutoSettlementAt,
Expand Down Expand Up @@ -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<OrchestrationV2Command, { readonly type: "thread.auto-settle" }>;

Expand Down
1 change: 1 addition & 0 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
152 changes: 1 addition & 151 deletions packages/client-runtime/src/work-log/userInput.ts
Original file line number Diff line number Diff line change
@@ -1,161 +1,11 @@
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<string, unknown> | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}

const isQuestionAnswer = Schema.is(UserInputAttachmentAnswerPayload);

function displayOptionAnswer(value: unknown, labels: ReadonlyMap<string, string>): 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<unknown>,
): 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<OrchestrationThreadActivity>,
): ReadonlyArray<OrchestrationThreadActivity> {
const questions = new Set<string>();
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<string>();
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<OrchestrationThreadActivity>,
): ReadonlyArray<OrchestrationThreadActivity> {
const requests = new Map<string, OrchestrationThreadActivity[]>();
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<OrchestrationThreadActivity, OrchestrationThreadActivity | null>();
for (const [requestId, group] of requests) {
const payloads = group.map((activity) => record(activity.payload)!);
const questions = new Map<string, Record<string, unknown>>();
const texts = new Map<string, unknown>();
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<string, string>();
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(", ");
Expand Down
33 changes: 0 additions & 33 deletions packages/shared/src/toolActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,36 +259,3 @@ export function deriveToolActivityPresentation(
summary: title ?? fallbackSummary,
};
}

export function projectQuestionToolInput(data: Record<string, unknown>, 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,
),
};
}),
},
};
}
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading