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
160 changes: 160 additions & 0 deletions assistant/src/home/__tests__/relationship-state-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ mock.module("../../memory/conversation-queries.js", () => ({
const {
backfillRelationshipStateIfMissing,
computeRelationshipState,
getOnboardingSidecarPath,
getRelationshipStatePath,
ONBOARDING_SIDECAR_FILENAME,
RELATIONSHIP_STATE_FILENAME,
writeOnboardingSidecar,
writeRelationshipState,
} = await import("../relationship-state-writer.js");

Expand Down Expand Up @@ -577,4 +580,161 @@ describe("relationship-state-writer", () => {
expect(existsSync(getRelationshipStatePath())).toBe(true);
});
});

describe("onboarding sidecar (JARVIS-471)", () => {
test("getOnboardingSidecarPath returns <workspace>/data/onboarding-context.json", () => {
expect(getOnboardingSidecarPath()).toBe(
join(workspaceDir, "data", ONBOARDING_SIDECAR_FILENAME),
);
});

test("writeOnboardingSidecar persists the payload to disk", () => {
writeOnboardingSidecar({
tools: ["Slack", "Gmail"],
tasks: ["Inbox triage"],
tone: "Dry and precise",
userName: "Alex",
assistantName: "Nova",
});

const path = getOnboardingSidecarPath();
expect(existsSync(path)).toBe(true);
const decoded = JSON.parse(readFileSync(path, "utf-8")) as {
tools: string[];
tasks: string[];
tone: string;
userName?: string;
assistantName?: string;
};
expect(decoded.tools).toEqual(["Slack", "Gmail"]);
expect(decoded.tasks).toEqual(["Inbox triage"]);
expect(decoded.tone).toBe("Dry and precise");
expect(decoded.userName).toBe("Alex");
expect(decoded.assistantName).toBe("Nova");
});

test("computeRelationshipState emits onboarding-sourced facts when the sidecar is present", async () => {
writeOnboardingSidecar({
tools: ["Slack", "Gmail", "Notion"],
tasks: ["Email triage", "Meeting prep"],
tone: "Friendly and warm",
userName: "Alex",
assistantName: "Nova",
});

const state = (await computeRelationshipState()) as RelationshipStateLike;

const onboardingFacts = state.facts.filter(
(f) => f.source === "onboarding",
);
expect(onboardingFacts).toHaveLength(6); // 3 tools + 2 tasks + 1 tone

const worldTexts = onboardingFacts
.filter((f) => f.category === "world")
.map((f) => f.text);
expect(worldTexts).toEqual(["Slack", "Gmail", "Notion"]);

const prioritiesTexts = onboardingFacts
.filter((f) => f.category === "priorities")
.map((f) => f.text);
expect(prioritiesTexts).toEqual(["Email triage", "Meeting prep"]);

const voiceTexts = onboardingFacts
.filter((f) => f.category === "voice")
.map((f) => f.text);
expect(voiceTexts).toEqual(["Friendly and warm"]);

for (const f of onboardingFacts) {
expect(f.confidence).toBe("strong");
expect(f.id.startsWith("onboarding-")).toBe(true);
}
});

test("sidecar userName / assistantName fill in when IDENTITY.md and USER.md are absent", async () => {
writeOnboardingSidecar({
tools: [],
tasks: [],
tone: "",
userName: "Alex",
assistantName: "Nova",
});

const state = (await computeRelationshipState()) as RelationshipStateLike;
expect(state.userName).toBe("Alex");
expect(state.assistantName).toBe("Nova");
});

test("IDENTITY.md / USER.md take precedence over sidecar names", async () => {
writeFile("IDENTITY.md", "- Name: RealAssistant\n");
writeFile("USER.md", "- Preferred name: RealUser\n");
writeOnboardingSidecar({
tools: [],
tasks: [],
tone: "",
userName: "StaleOnboardingUser",
assistantName: "StaleOnboardingAssistant",
});

const state = (await computeRelationshipState()) as RelationshipStateLike;
expect(state.userName).toBe("RealUser");
expect(state.assistantName).toBe("RealAssistant");
});

test("onboarding and inferred facts coexist with correct sources", async () => {
writeFile(
"USER.md",
["- Preferred name: Alex", "- Work role: Staff engineer"].join("\n"),
);
writeFile("SOUL.md", "- Tone: dry, precise\n");
writeOnboardingSidecar({
tools: ["Slack"],
tasks: ["Email triage"],
tone: "Friendly",
userName: "Alex",
});

const state = (await computeRelationshipState()) as RelationshipStateLike;
const bySource = new Map<string, number>();
for (const f of state.facts) {
bySource.set(f.source, (bySource.get(f.source) ?? 0) + 1);
}
expect(bySource.get("onboarding")).toBe(3); // 1 tool + 1 task + 1 tone
expect(bySource.get("inferred") ?? 0).toBeGreaterThanOrEqual(2);
// Onboarding facts render first so they lead the Home chip list.
expect(state.facts[0]?.source).toBe("onboarding");
});

test("missing sidecar produces no onboarding-sourced facts", async () => {
writeFile("USER.md", "- Preferred name: Alex");
const state = (await computeRelationshipState()) as RelationshipStateLike;
for (const f of state.facts) {
expect(f.source).toBe("inferred");
}
});

test("empty / whitespace-only onboarding entries are skipped", async () => {
writeOnboardingSidecar({
tools: ["Slack", "", " "],
tasks: [" ", "Email"],
tone: " ",
});
const state = (await computeRelationshipState()) as RelationshipStateLike;
const onboardingFacts = state.facts.filter(
(f) => f.source === "onboarding",
);
expect(onboardingFacts.map((f) => f.text).sort()).toEqual([
"Email",
"Slack",
]);
});

test("corrupt sidecar JSON degrades to zero onboarding facts", async () => {
mkdirSync(join(workspaceDir, "data"), { recursive: true });
writeFileSync(getOnboardingSidecarPath(), "{not valid json", "utf-8");
const state = (await computeRelationshipState()) as RelationshipStateLike;
for (const f of state.facts) {
expect(f.source).not.toBe("onboarding");
}
});
});
});
130 changes: 124 additions & 6 deletions assistant/src/home/relationship-state-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { resolveGuardianPersonaPath } from "../prompts/persona-resolver.js";
import { buildAssistantEvent } from "../runtime/assistant-event.js";
import { assistantEventHub } from "../runtime/assistant-event-hub.js";
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
import type { OnboardingContext } from "../types/onboarding-context.js";
import { getLogger } from "../util/logger.js";
import {
getDataDir,
Expand All @@ -55,6 +56,17 @@ const log = getLogger("relationship-state-writer");
*/
export const RELATIONSHIP_STATE_FILENAME = "relationship-state.json";

/**
* Filename for the pre-chat onboarding sidecar. Lives under the workspace
* data dir alongside `relationship-state.json`. Written once by the
* `POST /v1/messages` handler on first message and read on every
* `computeRelationshipState()` call so onboarding-sourced facts survive
* the pure-recomputation write cycle (every turn boundary rebuilds facts
* from scratch — without the sidecar, onboarding chips would vanish on
* turn 2).
*/
export const ONBOARDING_SIDECAR_FILENAME = "onboarding-context.json";

/**
* Conversation-count threshold at which the "voice-writing" capability
* flips from `earned` (gated, shown with an `unlockHint`) to `unlocked`.
Expand All @@ -79,6 +91,60 @@ export function getRelationshipStatePath(): string {
return join(getDataDir(), RELATIONSHIP_STATE_FILENAME);
}

/**
* Canonical path to the onboarding sidecar
* (`<workspace>/data/onboarding-context.json`).
*/
export function getOnboardingSidecarPath(): string {
return join(getDataDir(), ONBOARDING_SIDECAR_FILENAME);
}

/**
* Persist the pre-chat onboarding context to the sidecar file. Called
* once from the first-message path in `handleSendMessage`. Never throws
* — a failed write degrades to "no onboarding facts on the Home page",
* which is the same state as a skipped onboarding flow.
*/
export function writeOnboardingSidecar(ctx: OnboardingContext): void {
try {
mkdirSync(getDataDir(), { recursive: true });
writeFileSync(
getOnboardingSidecarPath(),
JSON.stringify(ctx, null, 2),
"utf-8",
);
log.info(
{
path: getOnboardingSidecarPath(),
tools: ctx.tools.length,
tasks: ctx.tasks.length,
},
"Wrote onboarding-context.json sidecar",
);
} catch (err) {
log.warn({ err }, "Failed to write onboarding-context.json sidecar");
}
}

/**
* Read and parse the onboarding sidecar, returning null when the file
* is missing or unreadable. Used by `computeRelationshipState()` to
* inject onboarding-sourced facts alongside the inferred ones.
*/
function readOnboardingSidecar(): OnboardingContext | null {
try {
const path = getOnboardingSidecarPath();
if (!existsSync(path)) return null;
const parsed = JSON.parse(readFileSync(path, "utf-8")) as OnboardingContext;
if (!parsed || !Array.isArray(parsed.tools) || !Array.isArray(parsed.tasks))
return null;
Comment on lines +139 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate sidecar field types before using string methods

readOnboardingSidecar() only checks that tools and tasks are arrays, but computeRelationshipState() later assumes every entry (and tone) is a string and calls .trim() on them. A malformed persisted payload (e.g. numeric tool/tone values) will throw during state computation, causing live home-state computation to fail and relationship-state writes to degrade. The sidecar parser should validate/sanitize element types (and tone/name types) before returning data.

Useful? React with 👍 / 👎.

return parsed;
} catch (err) {
log.warn({ err }, "Failed to read onboarding-context.json sidecar");
return null;
}
}

/**
* Build a fresh `RelationshipState` snapshot from the current workspace.
* Reads USER.md / SOUL.md / IDENTITY.md, queries the oauth connection
Expand All @@ -104,15 +170,30 @@ export async function computeRelationshipState(): Promise<RelationshipState> {
const userMd = resolveGuardianUserContent();
const soulMd = safeRead(getWorkspacePromptPath("SOUL.md"));
const identityPath = getWorkspacePromptPath("IDENTITY.md");
const onboarding = readOnboardingSidecar();

const facts = extractFacts({
userContent: userMd,
soulContent: soulMd,
onboarding,
});
const conversationCount = countConversations();
const capabilities = resolveCapabilityTiers({ conversationCount });
const { assistantName, hatchedDate } = parseIdentity(identityPath);
const userName = parseUserName(userMd);
const { assistantName: identityName, hatchedDate } =
parseIdentity(identityPath);
const parsedUserName = parseUserName(userMd);

// Fall back to onboarding sidecar values when IDENTITY.md / USER.md
// haven't yielded anything yet. On a brand-new workspace the sidecar
// is often the only source of these names until the daemon parses the
// markdown files on a subsequent turn.
const sidecarAssistantName = onboarding?.assistantName?.trim();
const assistantName =
identityName !== DEFAULT_ASSISTANT_NAME || !sidecarAssistantName
? identityName
: sidecarAssistantName;
const userName =
parsedUserName ?? (onboarding?.userName?.trim() || undefined);

const tier = computeTier({ facts, capabilities, conversationCount });
const progressPercent = computeProgressPercent({
Expand Down Expand Up @@ -314,10 +395,7 @@ function resolveGuardianUserContent(): string {
const defaultContent = safeRead(defaultUserPath);
if (defaultContent) return defaultContent;
} catch (err) {
log.warn(
{ err },
"Failed to read users/default.md; trying legacy USER.md",
);
log.warn({ err }, "Failed to read users/default.md; trying legacy USER.md");
}

// Legacy fallback: workspace-root USER.md for very old workspaces
Expand Down Expand Up @@ -359,6 +437,7 @@ function safeRead(path: string): string {
function extractFacts(input: {
userContent: string;
soulContent: string;
onboarding?: OnboardingContext | null;
}): Fact[] {
const facts: Fact[] = [];
let counter = 0;
Expand All @@ -367,6 +446,45 @@ function extractFacts(input: {
return `${prefix}-${counter}`;
};

// Onboarding-sourced facts come first so they render at the top of
// the Home page chip list until enough inferred facts accumulate to
// displace them. Each tool/task/tone line the user picked becomes a
// dashed-border chip tagged `source: "onboarding"`.
if (input.onboarding) {
for (const tool of input.onboarding.tools) {
const text = tool.trim();
if (!text) continue;
facts.push({
id: nextId("onboarding"),
category: "world",
text,
confidence: "strong",
source: "onboarding",
});
}
for (const task of input.onboarding.tasks) {
const text = task.trim();
if (!text) continue;
facts.push({
id: nextId("onboarding"),
category: "priorities",
text,
confidence: "strong",
source: "onboarding",
});
}
const tone = input.onboarding.tone?.trim();
if (tone) {
facts.push({
id: nextId("onboarding"),
category: "voice",
text: tone,
confidence: "strong",
source: "onboarding",
});
}
}

// Heuristic keyword map for USER.md sections -> fact category. Keys
// are matched case-insensitively as a prefix of the heading/bullet
// label. Everything that doesn't match stays a "world" fact.
Expand Down
Loading
Loading