feat: Slice 1 polish - MiMo V2.5 model, visible reasoning, image attachments, mobile keyboard fix
- Switch conversation agent to xiaomi/mimo-v2.5 (multimodal: text + image) - Render native reasoning parts as live 'Thinking trace' (streaming open, collapsed after completion); inline <think> extraction for streaming models - Image attachments: picker (up to 4, 10MB each), base64 to Flue AgentPromptImage, authenticated blob-URL replay for historical images - Mobile keyboard viewport fix: visual-viewport hook, fixed shell, interactive-widget=resizes-content, header pinned, composer follows keyboard - Conversation to Signal to proposed Work: Convex persistence, Effect validation in @code/work-os, Work cards with exact source provenance - Streamdown markdown + Mermaid chart rendering in chat messages - Flue tool turns hidden, reasoning-containing turns remain visible - Frontend regression tests: keyboard viewport, responsive shell, attachment overflow, authenticated images, reasoning traces, transforms - .env.example updated to xiaomi/mimo-v2.5 config
This commit is contained in:
2
packages/backend/convex/_generated/api.d.ts
vendored
2
packages/backend/convex/_generated/api.d.ts
vendored
@@ -30,6 +30,7 @@ import type * as signalRouting from "../signalRouting.js";
|
||||
import type * as signals from "../signals.js";
|
||||
import type * as todos from "../todos.js";
|
||||
import type * as workflows from "../workflows.js";
|
||||
import type * as works from "../works.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
@@ -60,6 +61,7 @@ declare const fullApi: ApiFromModules<{
|
||||
signals: typeof signals;
|
||||
todos: typeof todos;
|
||||
workflows: typeof workflows;
|
||||
works: typeof works;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -282,6 +282,41 @@ export default defineSchema({
|
||||
"messageId",
|
||||
]),
|
||||
|
||||
works: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
title: v.string(),
|
||||
objective: v.string(),
|
||||
status: v.literal("proposed"),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"])
|
||||
.index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
|
||||
|
||||
signalWorkAttachments: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
signalId: v.id("signals"),
|
||||
workId: v.id("works"),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_signal", ["signalId"])
|
||||
.index("by_work", ["workId"])
|
||||
.index("by_signal_and_work", ["signalId", "workId"]),
|
||||
|
||||
workEvents: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
workId: v.id("works"),
|
||||
kind: v.union(v.literal("work.proposed"), v.literal("signal.attached")),
|
||||
idempotencyKey: v.string(),
|
||||
data: v.any(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_work_and_createdAt", ["workId", "createdAt"])
|
||||
.index("by_work_and_idempotencyKey", ["workId", "idempotencyKey"]),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Signal-to-issue attachments. A proper relation that links one Signal
|
||||
// to one ProjectIssue, idempotent by (signalId, issueId). Multiple
|
||||
|
||||
224
packages/backend/convex/works.test.ts
Normal file
224
packages/backend/convex/works.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
const identity = { tokenIdentifier: "https://convex.test|slice-one" };
|
||||
|
||||
describe("Slice 1 Work routing", () => {
|
||||
test("creates one proposed Work and preserves exact source text", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const organization = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const project = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
remote: { defaultBranch: "main", documents: [], warnings: [] },
|
||||
source: {
|
||||
host: "github.com",
|
||||
normalizedUrl: "https://github.com/example/slice-one",
|
||||
projectName: "slice-one",
|
||||
repositoryPath: "example/slice-one",
|
||||
url: "https://github.com/example/slice-one",
|
||||
},
|
||||
userId: identity.tokenIdentifier,
|
||||
});
|
||||
const rawText = " Add a phone-ready Slice 1 experience. ";
|
||||
const message = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "slice-one-request",
|
||||
organizationId: organization._id,
|
||||
rawText,
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "slice-one-request",
|
||||
organizationId: organization._id,
|
||||
submissionId: "slice-one-submission",
|
||||
});
|
||||
const signal = await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [message.messageId],
|
||||
organizationId: organization._id,
|
||||
problemStatement: {
|
||||
constraints: ["Mobile web first"],
|
||||
desiredOutcome: "The Slice 1 loop works on a phone.",
|
||||
summary: "The current product is not ready for phone testing.",
|
||||
title: "Make Slice 1 phone-ready",
|
||||
},
|
||||
processedByAgentInstanceId: organization._id,
|
||||
projectId: project.id,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
|
||||
const first = await t.mutation(api.works.createFromSignal, {
|
||||
organizationId: organization._id,
|
||||
signalId: signal.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
const repeated = await t.mutation(api.works.createFromSignal, {
|
||||
organizationId: organization._id,
|
||||
signalId: signal.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
expect(repeated).toEqual({ created: false, workId: first.workId });
|
||||
|
||||
const works = await t
|
||||
.withIdentity(identity)
|
||||
.query(api.works.listForProject, { projectId: project.id });
|
||||
expect(works).toHaveLength(1);
|
||||
expect(works[0]?.status).toBe("proposed");
|
||||
expect(works[0]?.signals[0]?.sources[0]?.rawText).toBe(rawText);
|
||||
});
|
||||
|
||||
test("keeps casual conversation out of Work", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const organization = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const project = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
remote: { defaultBranch: "main", documents: [], warnings: [] },
|
||||
source: {
|
||||
host: "git.openputer.com",
|
||||
normalizedUrl: "https://git.openputer.com/puter/zopu-code",
|
||||
projectName: "zopu-code",
|
||||
repositoryPath: "puter/zopu-code",
|
||||
url: "https://git.openputer.com/puter/zopu-code",
|
||||
},
|
||||
userId: identity.tokenIdentifier,
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "slice-one-casual",
|
||||
organizationId: organization._id,
|
||||
rawText: "Yo, how are you?",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "slice-one-casual",
|
||||
organizationId: organization._id,
|
||||
submissionId: "slice-one-casual-submission",
|
||||
});
|
||||
|
||||
const works = await t
|
||||
.withIdentity(identity)
|
||||
.query(api.works.listForProject, { projectId: project.id });
|
||||
expect(works).toEqual([]);
|
||||
});
|
||||
|
||||
test("attaches another Signal to existing Work idempotently", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const organization = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const project = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
remote: { defaultBranch: "main", documents: [], warnings: [] },
|
||||
source: {
|
||||
host: "git.openputer.com",
|
||||
normalizedUrl: "https://git.openputer.com/puter/zopu-code",
|
||||
projectName: "zopu-code",
|
||||
repositoryPath: "puter/zopu-code",
|
||||
url: "https://git.openputer.com/puter/zopu-code",
|
||||
},
|
||||
userId: identity.tokenIdentifier,
|
||||
});
|
||||
|
||||
const seedSignal = async (
|
||||
clientRequestId: string,
|
||||
rawText: string,
|
||||
title: string
|
||||
) => {
|
||||
const message = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId,
|
||||
organizationId: organization._id,
|
||||
rawText,
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId,
|
||||
organizationId: organization._id,
|
||||
submissionId: `${clientRequestId}-submission`,
|
||||
});
|
||||
return await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [message.messageId],
|
||||
organizationId: organization._id,
|
||||
problemStatement: {
|
||||
constraints: [],
|
||||
desiredOutcome: "The Slice 1 phone flow is polished.",
|
||||
summary: "The messages describe the same desired outcome.",
|
||||
title,
|
||||
},
|
||||
processedByAgentInstanceId: organization._id,
|
||||
projectId: project.id,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
};
|
||||
|
||||
const firstSignal = await seedSignal(
|
||||
"slice-one-first",
|
||||
"Polish the Slice 1 phone experience.",
|
||||
"Polish Slice 1"
|
||||
);
|
||||
const secondSignal = await seedSignal(
|
||||
"slice-one-second",
|
||||
"Make the same Slice 1 experience easier to inspect on mobile.",
|
||||
"Improve Slice 1 inspection"
|
||||
);
|
||||
const created = await t.mutation(api.works.createFromSignal, {
|
||||
organizationId: organization._id,
|
||||
signalId: firstSignal.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
const firstAttach = await t.mutation(api.works.attachSignalToWork, {
|
||||
organizationId: organization._id,
|
||||
signalId: secondSignal.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
workId: created.workId,
|
||||
});
|
||||
const repeatedAttach = await t.mutation(api.works.attachSignalToWork, {
|
||||
organizationId: organization._id,
|
||||
signalId: secondSignal.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
workId: created.workId,
|
||||
});
|
||||
|
||||
expect(firstAttach).toEqual({ attached: true, workId: created.workId });
|
||||
expect(repeatedAttach).toEqual({
|
||||
attached: false,
|
||||
workId: created.workId,
|
||||
});
|
||||
|
||||
const works = await t
|
||||
.withIdentity(identity)
|
||||
.query(api.works.listForProject, { projectId: project.id });
|
||||
expect(works).toHaveLength(1);
|
||||
expect(works[0]?.signals).toHaveLength(2);
|
||||
expect(
|
||||
works[0]?.events.filter(
|
||||
(event: { readonly kind: string }) => event.kind === "signal.attached"
|
||||
)
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
267
packages/backend/convex/works.ts
Normal file
267
packages/backend/convex/works.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import {
|
||||
signalAttachedEvent,
|
||||
workDraftFromSignal,
|
||||
workProposedEventFromSignal,
|
||||
} from "@code/work-os";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { mutation, type MutationCtx, query } from "./_generated/server";
|
||||
import { requireProjectMember } from "./authz";
|
||||
|
||||
const requireAgent = (token: string) => {
|
||||
if (token !== env.FLUE_DB_TOKEN) {
|
||||
throw new ConvexError("Invalid agent control token");
|
||||
}
|
||||
};
|
||||
|
||||
const requireSignal = async (
|
||||
ctx: MutationCtx,
|
||||
organizationId: Id<"organizations">,
|
||||
signalId: Id<"signals">
|
||||
): Promise<Doc<"signals">> => {
|
||||
const signal = await ctx.db.get(signalId);
|
||||
if (!signal || signal.organizationId !== organizationId) {
|
||||
throw new ConvexError("Signal not found");
|
||||
}
|
||||
if (!signal.projectId) {
|
||||
throw new ConvexError("Signal must belong to a project");
|
||||
}
|
||||
return signal;
|
||||
};
|
||||
|
||||
const requireWork = async (
|
||||
ctx: MutationCtx,
|
||||
organizationId: Id<"organizations">,
|
||||
workId: Id<"works">
|
||||
): Promise<Doc<"works">> => {
|
||||
const work = await ctx.db.get(workId);
|
||||
if (!work || work.organizationId !== organizationId) {
|
||||
throw new ConvexError("Work not found");
|
||||
}
|
||||
return work;
|
||||
};
|
||||
|
||||
const attachSignal = async (
|
||||
ctx: MutationCtx,
|
||||
signal: Doc<"signals">,
|
||||
work: Doc<"works">
|
||||
): Promise<boolean> => {
|
||||
const event = await Effect.runPromise(
|
||||
signalAttachedEvent({
|
||||
signal: {
|
||||
organizationId: String(signal.organizationId),
|
||||
projectId: String(signal.projectId),
|
||||
signalId: String(signal._id),
|
||||
title: signal.problemStatement.title,
|
||||
},
|
||||
work: {
|
||||
organizationId: String(work.organizationId),
|
||||
projectId: String(work.projectId),
|
||||
workId: String(work._id),
|
||||
},
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid Work attachment"
|
||||
);
|
||||
});
|
||||
const existing = await ctx.db
|
||||
.query("signalWorkAttachments")
|
||||
.withIndex("by_signal_and_work", (q) =>
|
||||
q.eq("signalId", signal._id).eq("workId", work._id)
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const createdAt = Date.now();
|
||||
await ctx.db.insert("signalWorkAttachments", {
|
||||
createdAt,
|
||||
organizationId: work.organizationId,
|
||||
projectId: work.projectId,
|
||||
signalId: signal._id,
|
||||
workId: work._id,
|
||||
});
|
||||
await ctx.db.insert("workEvents", {
|
||||
createdAt,
|
||||
data: event.data,
|
||||
idempotencyKey: event.idempotencyKey,
|
||||
kind: event.kind,
|
||||
organizationId: work.organizationId,
|
||||
projectId: work.projectId,
|
||||
workId: work._id,
|
||||
});
|
||||
await ctx.db.patch(work._id, { updatedAt: createdAt });
|
||||
return true;
|
||||
};
|
||||
|
||||
export const listProposedForAgent = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const project = await ctx.db.get(args.projectId);
|
||||
if (!project || project.organizationId !== args.organizationId) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
return await ctx.db
|
||||
.query("works")
|
||||
.withIndex("by_project_and_createdAt", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(50);
|
||||
},
|
||||
});
|
||||
|
||||
export const createFromSignal = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
signalId: v.id("signals"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const signal = await requireSignal(ctx, args.organizationId, args.signalId);
|
||||
const existingAttachment = await ctx.db
|
||||
.query("signalWorkAttachments")
|
||||
.withIndex("by_signal", (q) => q.eq("signalId", signal._id))
|
||||
.first();
|
||||
if (existingAttachment) {
|
||||
return { created: false, workId: existingAttachment.workId };
|
||||
}
|
||||
|
||||
const draft = await Effect.runPromise(
|
||||
workDraftFromSignal(signal.problemStatement)
|
||||
).catch((error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid Work proposal"
|
||||
);
|
||||
});
|
||||
const createdAt = Date.now();
|
||||
const event = await Effect.runPromise(
|
||||
workProposedEventFromSignal({
|
||||
organizationId: String(signal.organizationId),
|
||||
projectId: String(signal.projectId),
|
||||
signalId: String(signal._id),
|
||||
title: draft.title,
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid Work event"
|
||||
);
|
||||
});
|
||||
const workId = await ctx.db.insert("works", {
|
||||
createdAt,
|
||||
objective: draft.objective,
|
||||
organizationId: signal.organizationId,
|
||||
projectId: signal.projectId as Id<"projects">,
|
||||
status: "proposed",
|
||||
title: draft.title,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
await ctx.db.insert("signalWorkAttachments", {
|
||||
createdAt,
|
||||
organizationId: signal.organizationId,
|
||||
projectId: signal.projectId as Id<"projects">,
|
||||
signalId: signal._id,
|
||||
workId,
|
||||
});
|
||||
await ctx.db.insert("workEvents", {
|
||||
createdAt,
|
||||
data: event.data,
|
||||
idempotencyKey: event.idempotencyKey,
|
||||
kind: event.kind,
|
||||
organizationId: signal.organizationId,
|
||||
projectId: signal.projectId as Id<"projects">,
|
||||
workId,
|
||||
});
|
||||
return { created: true, workId };
|
||||
},
|
||||
});
|
||||
|
||||
export const attachSignalToWork = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
signalId: v.id("signals"),
|
||||
token: v.string(),
|
||||
workId: v.id("works"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const signal = await requireSignal(ctx, args.organizationId, args.signalId);
|
||||
const work = await requireWork(ctx, args.organizationId, args.workId);
|
||||
return {
|
||||
attached: await attachSignal(ctx, signal, work),
|
||||
workId: work._id,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const listForProject = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
const works = await ctx.db
|
||||
.query("works")
|
||||
.withIndex("by_project_and_createdAt", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
|
||||
return await Promise.all(
|
||||
works.map(async (work) => {
|
||||
const attachments = await ctx.db
|
||||
.query("signalWorkAttachments")
|
||||
.withIndex("by_work", (q) => q.eq("workId", work._id))
|
||||
.collect();
|
||||
const signals = await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
const signal = await ctx.db.get(attachment.signalId);
|
||||
if (!signal) {
|
||||
return null;
|
||||
}
|
||||
const sources = await ctx.db
|
||||
.query("signalSources")
|
||||
.withIndex("by_signal_and_ordinal", (q) =>
|
||||
q.eq("signalId", signal._id)
|
||||
)
|
||||
.collect();
|
||||
return {
|
||||
createdAt: signal.createdAt,
|
||||
signalId: signal._id,
|
||||
summary: signal.problemStatement.summary,
|
||||
sources: sources
|
||||
.sort((a, b) => a.ordinal - b.ordinal)
|
||||
.map((source) => ({
|
||||
createdAt: source.sourceCreatedAt,
|
||||
messageId: source.messageId,
|
||||
rawText: source.rawTextSnapshot,
|
||||
submissionId: source.submissionId ?? null,
|
||||
})),
|
||||
title: signal.problemStatement.title,
|
||||
};
|
||||
})
|
||||
);
|
||||
const events = await ctx.db
|
||||
.query("workEvents")
|
||||
.withIndex("by_work_and_createdAt", (q) => q.eq("workId", work._id))
|
||||
.order("desc")
|
||||
.collect();
|
||||
return {
|
||||
...work,
|
||||
events,
|
||||
signals: signals.filter((signal) => signal !== null),
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user