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
6 changes: 2 additions & 4 deletions apps/mobile/src/components/ProjectKnowledgeEntry.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ const fact: ProjectFact = {
tags: [],
kind: "note",
sources: [],
status: "proposed",
status: "saved",
revision: 1,
trust: 0.5,
retrievalCount: 0,
Expand Down Expand Up @@ -486,7 +486,6 @@ it("shows user origin once without treating the assertion as a citation", async
const asserted: ProjectFact = {
...fact,
origin: { kind: "user" },
status: "confirmed",
sources: [{ kind: "user" }],
};
state.list = { facts: [asserted], nextCursor: null };
Expand Down Expand Up @@ -681,7 +680,6 @@ it("marks a superseded fact read-only and lists superseded facts on request", as
await screen.click(`Inspect fact: ${fact.content}`);
const labels = [...screen.container.querySelectorAll("button")].map((item) => item.textContent);
expect(labels).not.toContain("Edit");
expect(labels).not.toContain("Confirm");
expect(labels).not.toContain("Supersede");
await screen.click("Back");
expect(state.listInputs.at(-1)).not.toHaveProperty("includeSuperseded");
Expand Down Expand Up @@ -841,7 +839,7 @@ it("drops a revised fact from the selection and the draft after saving", async (
});
screen.openMemory();
await screen.click(`Inspect fact: ${fact.content}`);
await screen.click("Confirm");
await screen.click("Helpful");
expect(state.setText).toHaveBeenCalledWith(scope.draftKey, "");
expect(screen.container.textContent).toContain("0 selected");
expect(screen.container.textContent).toContain("removed from the draft");
Expand Down
13 changes: 7 additions & 6 deletions apps/mobile/src/components/ProjectKnowledgeEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,8 @@ function ProjectKnowledgeSheet(
<ScrollView
className="flex-1"
contentInsetAdjustmentBehavior="automatic"
automaticallyAdjustKeyboardInsets
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ padding: 20, gap: 14 }}
>
Expand Down Expand Up @@ -502,18 +504,17 @@ function ProjectKnowledgeSheet(
if (inspectedFact.status !== "forgotten")
push({ kind: "editor", mode: { kind, fact: inspectedFact } });
}}
onRevise={(action) => {
const revise = () => void apply({ action, ...factMutation(inspectedFact) });
if (action !== "forget") return revise();
onForget={() =>
Dialog.confirm({
title: "Forget this fact permanently?",
message:
"Its content, history and every copy sent to agents are removed and cannot be restored. If the fact changed rather than became wrong, supersede it instead.",
confirmText: "Forget",
destructive: true,
onConfirm: revise,
});
}}
onConfirm: () =>
void apply({ action: "forget", ...factMutation(inspectedFact) }),
})
}
onFeedback={(helpful) =>
void apply({ action: "feedback", helpful, ...factMutation(inspectedFact) })
}
Expand Down
30 changes: 5 additions & 25 deletions apps/mobile/src/components/ProjectKnowledgeFactDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export function ProjectFactInspector(props: {
readonly onReadSource: (ref: KnowledgeMessageRef) => void;
readonly onOpenFact: OpenFact;
readonly onEdit: (mode: "edit" | "supersede") => void;
readonly onRevise: (action: "confirm" | "forget") => void;
readonly onForget: () => void;
readonly onFeedback: (helpful: boolean) => void;
readonly onDismissHints: () => void;
}) {
Expand Down Expand Up @@ -162,13 +162,6 @@ export function ProjectFactInspector(props: {
<AppText className="text-xs text-foreground-muted">
Trust signal {fact.trust.toFixed(2)} · revision {fact.revision}
</AppText>
{fact.status === "proposed" &&
fact.origin.kind === "assisted" &&
fact.supersedes != null ? (
<AppText className="text-xs">
Confirming replaces fact {fact.supersedes.slice(0, 8)}; forgetting leaves it as it is.
</AppText>
) : null}
</View>

<View className="gap-2">
Expand Down Expand Up @@ -202,14 +195,6 @@ export function ProjectFactInspector(props: {

{active ? (
<View className="flex-row flex-wrap gap-2">
{fact.status === "proposed" && !superseded ? (
<Button
label="Confirm"
tone="secondary"
disabled={!mutable}
onPress={() => props.onRevise("confirm")}
/>
) : null}
{!superseded ? (
<Button
label="Edit"
Expand Down Expand Up @@ -238,12 +223,7 @@ export function ProjectFactInspector(props: {
disabled={!mutable}
onPress={() => props.onFeedback(false)}
/>
<Button
label="Forget"
tone="danger"
disabled={!mutable}
onPress={() => props.onRevise("forget")}
/>
<Button label="Forget" tone="danger" disabled={!mutable} onPress={props.onForget} />
</View>
) : null}
{!props.canMutate && active ? (
Expand Down Expand Up @@ -358,9 +338,9 @@ export function KnowledgeHealthRow(props: {
return (
<View className="gap-1">
<AppText className="text-xs text-foreground-muted">
{status.counts.confirmed} confirmed · {status.counts.proposed} proposed ·{" "}
{status.counts.superseded} superseded · {status.belowRecallThreshold} below recall ·{" "}
{status.feedback.helpful} helpful · {status.feedback.unhelpful} unhelpful
{status.counts.saved} saved · {status.counts.superseded} superseded ·{" "}
{status.belowRecallThreshold} below recall · {status.feedback.helpful} helpful ·{" "}
{status.feedback.unhelpful} unhelpful
</AppText>
{lost > 0 ? (
<Button
Expand Down
25 changes: 11 additions & 14 deletions apps/mobile/src/components/ProjectKnowledgeFactList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { KnowledgeHealthRow } from "./ProjectKnowledgeFactDetail";
import { AppText, AppTextInput } from "./ui/AppText";
import { Button } from "./ui/Button";

type StatusFilter = "confirmed" | "proposed" | "suggested" | "forgotten";
type StatusFilter = "saved" | "automatic" | "forgotten";

/** Filters, search and paging for the fact list. The sheet reads its results for draft labels. */
export function useProjectKnowledgeBrowse(props: {
Expand All @@ -44,7 +44,7 @@ export function useProjectKnowledgeBrowse(props: {
readonly history: boolean;
readonly onArchivedChange: () => void;
}) {
const [status, setStatus] = useState<StatusFilter>("confirmed");
const [status, setStatus] = useState<StatusFilter>("saved");
const [afterId, setAfterId] = useState<ProjectFactId | undefined>();
const [includeArchived, setIncludeArchived] = useState(false);
const [searchText, setSearchText] = useState("");
Expand All @@ -56,11 +56,10 @@ export function useProjectKnowledgeBrowse(props: {
environmentId: props.environmentId,
input: {
projectId: props.projectId,
// The lost-source filter spans proposed and confirmed, matching the health count.
...(props.history && lostSourcesOnly
? { sourceState: "unavailable" as const }
: status === "suggested"
? { status: "proposed" as const, origin: "assisted" as const }
: status === "automatic"
? { status: "saved" as const, origin: "assisted" as const }
: { status }),
...(afterId ? { afterId } : {}),
...(props.history && showSuperseded ? { includeSuperseded: true } : {}),
Expand Down Expand Up @@ -214,7 +213,6 @@ export function ProjectKnowledgeFactList(props: {
}) {
const { browse } = props;
const { list, health, search, searchInput } = browse;
const suggested = health.data?.assistedLearning?.suggested;
return (
<View className="gap-4">
<View className="gap-2">
Expand Down Expand Up @@ -243,12 +241,12 @@ export function ProjectKnowledgeFactList(props: {

<View className="flex-row flex-wrap gap-2">
{(props.assisted
? (["confirmed", "proposed", "suggested", "forgotten"] as const)
: (["confirmed", "proposed", "forgotten"] as const)
? (["saved", "automatic", "forgotten"] as const)
: (["saved", "forgotten"] as const)
).map((value) => (
<Button
key={value}
label={value === "suggested" && suggested ? `suggested (${suggested})` : value}
label={value}
tone={
browse.status === value && !searchInput && !browse.lostSourcesOnly
? "primary"
Expand Down Expand Up @@ -427,7 +425,6 @@ function FactRow(props: {
);
}

/** The project's opt-in for model-suggested facts and what the last run did with it. */
function AssistedLearningRow(props: {
readonly environmentId: EnvironmentId;
readonly projectId: ProjectId;
Expand Down Expand Up @@ -463,17 +460,17 @@ function AssistedLearningRow(props: {
return (
<View className="gap-1 rounded-2xl bg-subtle p-3">
<View className="flex-row items-center gap-3">
<AppText className="flex-1">Suggest facts from finished runs</AppText>
<AppText className="flex-1">Save facts from finished runs</AppText>
<Switch
accessibilityLabel="Suggest facts from finished runs"
accessibilityLabel="Save facts from finished runs"
value={enabled}
disabled={!canWrite || busy}
onValueChange={(value) => void toggle(value)}
/>
</View>
<AppText className="text-xs text-foreground-muted">
After each run in this project, your text generation model proposes up to three cited facts
for review. Each run costs one model call.
After each run in this project, your text generation model saves up to three cited facts.
Each run costs one model call.
</AppText>
{props.lastRun ? (
<AppText className="text-xs">{assistedLearningRunLabel(props.lastRun)}</AppText>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,13 @@ describe("SettingsServerControlsRouteScreen", () => {
act(() => root.render(<SettingsZackRouteScreen />));
const toggle = Array.from(
container.querySelectorAll<HTMLButtonElement>('[role="switch"]'),
).find((button) => button.textContent === "Suggest facts from finished runs")!;
).find((button) => button.textContent === "Save facts from finished runs")!;

act(() => toggle.click());

expect(toggle.disabled).toBe(true);
expect(mocks.updateSettings).not.toHaveBeenCalled();
expect(container.textContent).toContain("Assisted learning is not supported on Old server.");
expect(container.textContent).toContain("Automatic memory is not supported on Old server.");
},
);

Expand All @@ -228,33 +228,35 @@ describe("SettingsServerControlsRouteScreen", () => {
act(() => root.render(<SettingsZackRouteScreen />));
const toggle = Array.from(
container.querySelectorAll<HTMLButtonElement>('[role="switch"]'),
).find((button) => button.textContent === "Suggest facts from finished runs")!;
).find((button) => button.textContent === "Save facts from finished runs")!;

act(() => toggle.click());

expect(toggle.disabled).toBe(true);
expect(mocks.updateSettings).not.toHaveBeenCalled();
expect(container.textContent).toContain(
"Assisted learning is supported on only some selected environments.",
"Automatic memory is supported on only some selected environments.",
);
expect(container.textContent).toContain("Old server");
});

it("enables assisted learning for a supported project without targeting unrelated older environments", async () => {
it("turns automatic memory off for a supported project without targeting unrelated older environments", async () => {
mocks.targets = [environment("new", "New server", true), environment("old", "Old server")];
mocks.projectSelected = true;
mocks.projectEnvironmentIds = ["new"];
act(() => root.render(<SettingsZackRouteScreen />));
const toggle = Array.from(
container.querySelectorAll<HTMLButtonElement>('[role="switch"]'),
).find((button) => button.textContent === "Suggest facts from finished runs")!;
).find((button) => button.textContent === "Save facts from finished runs")!;

await act(async () => toggle.click());

expect(toggle.disabled).toBe(false);
expect(mocks.updateSettings).toHaveBeenCalledExactlyOnceWith({
environmentId: "new",
input: { patch: { projectSettingsOverrides: { project: { enableAssistedLearning: true } } } },
input: {
patch: { projectSettingsOverrides: { project: { enableAssistedLearning: false } } },
},
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,8 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
unsupportedAssistedLearning.length === 0
? null
: unsupportedAssistedLearning.length < targets.length
? `Assisted learning is supported on only some selected environments. Update T3 Code on ${unsupportedAssistedLearning.map((target) => target.environment.label).join(", ")}, or select a supported environment.`
: `Assisted learning is not supported on ${unsupportedAssistedLearning.map((target) => target.environment.label).join(", ")}. Update T3 Code on ${unsupportedAssistedLearning.length === 1 ? "that environment" : "those environments"}.`;
? `Automatic memory is supported on only some selected environments. Update T3 Code on ${unsupportedAssistedLearning.map((target) => target.environment.label).join(", ")}, or select a supported environment.`
: `Automatic memory is not supported on ${unsupportedAssistedLearning.map((target) => target.environment.label).join(", ")}. Update T3 Code on ${unsupportedAssistedLearning.length === 1 ? "that environment" : "those environments"}.`;
const [pendingWrites, setPendingWrites] = useState(0);
const writeInFlight = useRef(false);
// Closing views can save after controls disable; queue those patches behind the pending write.
Expand Down Expand Up @@ -424,10 +424,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
<SettingsSection title="Project memory">
<SettingsSwitchRow
icon="brain"
label="Suggest facts from finished runs"
label="Save facts from finished runs"
subtitle={
assistedLearningUnavailableReason ??
"After each run, your text generation model proposes up to three cited facts for review. Each run costs one model call."
"After each run, your text generation model saves up to three cited facts. Each run costs one model call."
}
value={uniform("enableAssistedLearning")}
disabled={
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/mcp/toolkits/knowledge/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const KnowledgeToolkit = Toolkit.make(
Tool.make("t3_knowledge_list", {
...shared,
description:
"List project facts, including proposed or forgotten facts when requested. Superseded facts are excluded unless includeSuperseded is set; sourceState unavailable lists only facts whose citations no longer resolve. This works independently of the retrieval kernel.",
"List saved project facts, or forgotten facts when requested. Superseded facts are excluded unless includeSuperseded is set; sourceState unavailable lists only facts whose citations no longer resolve. This works independently of the retrieval kernel.",
parameters: Schema.Struct({
...ProjectKnowledgeListInput.fields,
environmentId: OrchestratorMcpEnvironmentTarget,
Expand All @@ -59,7 +59,7 @@ export const KnowledgeToolkit = Toolkit.make(
Tool.make("t3_knowledge_read_fact", {
...shared,
description:
"Read one project fact with its recent revision, confirmation, feedback and supersession history, linked superseding facts, and possible duplicates or conflicts found by lexical similarity.",
"Read one project fact with its recent revision, feedback and supersession history, linked superseding facts, and possible duplicates or conflicts found by lexical similarity.",
parameters: Schema.Struct({
...ProjectKnowledgeReadFactInput.fields,
environmentId: OrchestratorMcpEnvironmentTarget,
Expand Down Expand Up @@ -107,7 +107,7 @@ export const KnowledgeToolkit = Toolkit.make(
Tool.make("t3_knowledge_mutate", {
...shared,
description:
"Propose or edit project knowledge, supersede a fact with a replacement, forget a fact, or give trust feedback. When a fact comes from a conversation message, attach that exact message citation. Leave sources empty only for an agent observation with no message source; its origin is recorded from this authenticated caller. Proposed facts can be recalled immediately, but are not automatically added to the prompt. Confirmation in the app is optional and remains a separate trust action. Agents cannot confirm facts or claim direct user assertions. Reuse commandId when retrying. Updates require the current revision. Supersede creates replacementFactId and keeps the old fact's content but removes it from recall; prefer it over forget when knowledge changed. Created, edited and replacement facts return hints naming possible duplicates or conflicts to review. Forgotten facts cannot be restored.",
"Create or edit project knowledge, supersede a fact with a replacement, forget a fact, or give trust feedback. When a fact comes from a conversation message, attach that exact message citation. Leave sources empty only for an agent observation with no message source; its origin is recorded from this authenticated caller. Facts are saved immediately and can be recalled by search, but are not automatically added to the prompt. Saving records a claim with its origin, not proof that it is true. Agents cannot claim direct user assertions. Reuse commandId when retrying. Updates require the current revision. Supersede creates replacementFactId and keeps the old fact's content but removes it from recall; prefer it over forget when knowledge changed. Created, edited and replacement facts return hints naming possible duplicates or conflicts to review. Forgotten facts cannot be restored.",
parameters: Schema.Struct({
mutation: ProjectKnowledgeMutation,
environmentId: OrchestratorMcpEnvironmentTarget,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,7 @@ it.effect.each([
turnItems: [],
} as unknown as OrchestrationV2ThreadProjection;
const layer = ProviderRuntimeRecovery.layer.pipe(
Layer.provide(ServerSettings.layerTest()),
Layer.provide(ServerSettings.layerTest({ enableAssistedLearning: false })),
Layer.provide(
Layer.mergeAll(
Layer.mock(ProjectionStore.ProjectionStoreV2)({
Expand Down
11 changes: 9 additions & 2 deletions apps/server/src/orchestration-v2/RestartContinuation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ it.effect.each([
Layer.mergeAll(
ServerSettings.layerTest({
continueThreadsAfterServerUpdate: enabled,
enableAssistedLearning: false,
projectSettingsOverrides:
projectOverride === undefined
? {}
Expand Down Expand Up @@ -386,7 +387,10 @@ it.effect.each(["running", "waiting"])(
const recovery = yield* ProviderRuntimeRecovery.make.pipe(
Effect.provide(
Layer.mergeAll(
ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }),
ServerSettings.layerTest({
continueThreadsAfterServerUpdate: true,
enableAssistedLearning: false,
}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({
getRecoveryThreadIds: () => Effect.succeed([threadId]),
getRuntimeRecoveryProjection: () => Effect.succeed(projection),
Expand Down Expand Up @@ -633,7 +637,10 @@ it.effect.each([
const recovery = yield* ProviderRuntimeRecovery.make.pipe(
Effect.provide(
Layer.mergeAll(
ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }),
ServerSettings.layerTest({
continueThreadsAfterServerUpdate: true,
enableAssistedLearning: false,
}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({
getRecoveryThreadIds: () => Effect.succeed([threadId]),
getRuntimeRecoveryProjection: () => Effect.succeed(projection),
Expand Down
Loading
Loading