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
1 change: 1 addition & 0 deletions src/features/workspaces/ai/ai-thread-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,7 @@ export function getAIThreadSoulPrompt() {
"Never use private sandbox files as user-visible workspace items.",
"Do not claim to have read actual workspace content unless an actual workspace tool returned it.",
"Resolve this/it/that/here/above/the page/this file from current-turn context: selected quotes, then active view, then active/open items. Ask briefly before changes if ambiguous.",
"Treat workspace relationships as ambient navigation and provenance context. Use them silently to find and understand relevant items; do not present routine relationship maintenance as user-facing work. Mention relationships only when the user asks about them or when one materially affects the answer.",
"Web tools read public web content only.",
],
},
Expand Down
12 changes: 6 additions & 6 deletions src/features/workspaces/cache-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,16 @@ export function setWorkspacePageCache(
input: {
workspace: WorkspaceSummary;
items: WorkspaceItemSummary[];
itemFacts?: WorkspacePage["itemFacts"];
revision?: number;
itemFacts: WorkspacePage["itemFacts"];
revision: number;
},
) {
queryClient.setQueryData<WorkspacePage>(workspacePageQueryKey(input.workspace.id), (current) => ({
queryClient.setQueryData<WorkspacePage>(workspacePageQueryKey(input.workspace.id), {
workspace: input.workspace,
items: input.items,
itemFacts: input.itemFacts ?? current?.itemFacts ?? [],
revision: input.revision ?? current?.revision ?? 0,
}));
itemFacts: input.itemFacts,
revision: input.revision,
});
}

export function restoreWorkspaceListCache(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import type { WorkspaceClipboardIntake } from "#/features/workspaces/clipboard/w
import { getWorkspaceObjectRegistryEntry } from "#/features/workspaces/model/object-registry";
import { workspaceColors } from "#/features/workspaces/model/workspace-colors";
import { resolveWorkspaceFileTypeFromHint } from "#/features/workspaces/model/workspace-file";
import { workspaceItemTypeColors } from "#/features/workspaces/model/workspace-item-colors";
import { cn } from "#/lib/utils";

const documentDisplay = getWorkspaceObjectRegistryEntry("document");
const documentIconClassName = workspaceColors[workspaceItemTypeColors.document].iconClassName;
const fileIconClassName = workspaceColors[workspaceItemTypeColors.file].iconClassName;
const documentIconClassName = workspaceColors[documentDisplay.color].iconClassName;
const fileIconClassName =
workspaceColors[getWorkspaceObjectRegistryEntry("file").color].iconClassName;

export function WorkspaceClipboardIntakeDialog({
intake,
Expand Down
7 changes: 1 addition & 6 deletions src/features/workspaces/components/WorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,7 @@ export function WorkspaceShell({
}
applyWorkspaceEventToCache(queryClient, event);
},
onReconnect: () => {
void queryClient.invalidateQueries({
queryKey: workspacePageQueryKey(workspace.id),
});
},
onRevisionGap: () => {
onDesync: () => {
void queryClient.invalidateQueries({
queryKey: workspacePageQueryKey(workspace.id),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ export function getRunningToolReceipt(input: {
case "workspace_move_items":
return running(`Moving ${formatCount(getArray(toolInput.paths).length, "item")}`);
case "workspace_read_items":
return running(`Reading ${formatToolInputPaths(toolInput.paths)}`);
return running(
`Reading ${formatToolInputPaths(
getArray(toolInput.requests).map((request) => asRecord(request).path),
)}`,
);
case "workspace_rename_item":
return running(`Renaming ${quoteName(getBaseName(getString(toolInput.path)))}`);
case "web_links":
Expand Down Expand Up @@ -249,22 +253,35 @@ function summarizeWorkspaceList(output: unknown): AiChatToolReceipt {

function summarizeWorkspaceRead(output: unknown): AiChatToolReceipt {
const record = asRecord(output);
const items = getArray(record.items);
const failedCount =
getArray(record.failed).length +
items.filter((item) => getString(asRecord(item).status) === "failed").length;
const readyItems = items.filter((item) => getString(asRecord(item).status) === "ready");
const results = getArray(record.results);
const failedCount = results.filter(
(result) => getString(asRecord(result).status) === "failed",
).length;
const readyItems = results.filter((item) => getString(asRecord(item).status) === "ready");
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const pendingItems = results.filter((item) => getString(asRecord(item).status) === "pending");

if (readyItems.length === 0 && failedCount > 0) {
if (readyItems.length === 0 && pendingItems.length === 0 && failedCount > 0) {
return failed(`Couldn’t read ${formatCount(failedCount, "item")}`);
}
if (readyItems.length === 0) {
return completed(
appendFailureCount(
`Extraction in progress for ${formatCount(pendingItems.length, "item")}`,
failedCount,
),
);
}

const summary =
readyItems.length === 1
? `Read ${quoteName(getBaseName(getString(asRecord(readyItems[0]).path)))}`
: `Read ${formatCount(readyItems.length, "item")}`;

return completed(appendFailureCount(summary, failedCount));
const pendingSummary =
pendingItems.length > 0
? `${summary} · ${formatCount(pendingItems.length, "item")} still processing`
: summary;
return completed(appendFailureCount(pendingSummary, failedCount));
}

function summarizeWebSearch(output: unknown, toolInput: unknown) {
Expand Down
89 changes: 89 additions & 0 deletions src/features/workspaces/content/workspace-content-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { asSchema } from "ai";
import { describe, expect, it } from "vitest";

import {
workspaceReadItemsInputSchema,
workspaceReadItemsOutputSchema,
} from "#/features/workspaces/content/workspace-content-contract";

describe("workspace read tool schemas", () => {
it("uses one explicit read mode per request", () => {
expect(
workspaceReadItemsInputSchema.safeParse({
requests: [
{ mode: "start", path: "/Notes" },
{ mode: "pages", path: "/Book.pdf", range: "1-3" },
{ cursor: "opaque", mode: "continue", path: "/Notes" },
],
}).success,
).toBe(true);
expect(
workspaceReadItemsInputSchema.safeParse({
requests: [{ path: "/Book.pdf", pages: "1-3" }],
}).success,
).toBe(false);
expect(
workspaceReadItemsInputSchema.safeParse({ requests: [{ path: "/Notes" }] }).success,
).toBe(false);
});

it("emits a strict-provider-compatible JSON Schema", () => {
const { jsonSchema } = asSchema(workspaceReadItemsInputSchema);

expect(jsonSchema).toMatchObject({
additionalProperties: false,
properties: {
requests: {
items: {
anyOf: [
{ additionalProperties: false, required: ["path", "mode"] },
{ additionalProperties: false, required: ["path", "mode", "range"] },
{ additionalProperties: false, required: ["path", "cursor", "mode"] },
],
},
},
},
required: ["requests"],
});
expect(JSON.stringify(jsonSchema)).not.toContain('"oneOf"');
});

it("keeps document and file result shapes disjoint", () => {
expect(
workspaceReadItemsOutputSchema.safeParse({
results: [
{
content: "# Notes",
format: "markdown",
location: { endLine: 1, kind: "lines", startLine: 1, totalLines: 1 },
path: "/Notes",
status: "ready",
type: "document",
},
{
content: "Page one",
format: "markdown",
location: { kind: "pages", requested: "1", returned: [1], total: 1 },
path: "/Book.pdf",
status: "ready",
type: "file",
},
],
}).success,
).toBe(true);
expect(
workspaceReadItemsOutputSchema.safeParse({
results: [
{
content: "Page one",
format: "markdown",
location: { kind: "pages", requested: "1", returned: [1], total: 1 },
path: "/Notes",
status: "ready",
type: "document",
},
],
}).success,
).toBe(false);
});
});
118 changes: 118 additions & 0 deletions src/features/workspaces/content/workspace-content-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { z } from "zod";

import { workspaceRelationKindSchema } from "#/features/workspaces/contracts";

const workspacePathSchema = z.string().min(1);

const readWorkspaceItemsFailureCodes = [
"content_changed",
"invalid_cursor",
"invalid_selection",
"page_range_out_of_range",
"page_selection_too_large",
"read_budget_exceeded",
"path_is_folder",
"path_not_absolute",
"path_not_found",
"projection_failed",
"unsupported_item_type",
] as const;

const workspacePageRangeSchema = z
.string()
.trim()
.min(1)
.regex(/^\d+(?:\s*-\s*\d+)?(?:\s*,\s*\d+(?:\s*-\s*\d+)?)*$/)
.describe(
"Up to 20 physical pages from an extracted file, like 1, 3, 5-7, or 1,4-6. Defaults to page 1.",
);

const workspaceContentReadRequestBase = {
path: workspacePathSchema.describe("Absolute path of the workspace item to read."),
};

const workspaceContentReadRequestSchema = z.union([
z.strictObject({
...workspaceContentReadRequestBase,
mode: z.literal("start"),
}),
z.strictObject({
...workspaceContentReadRequestBase,
mode: z.literal("pages"),
range: workspacePageRangeSchema,
}),
z.strictObject({
...workspaceContentReadRequestBase,
cursor: z.string().min(1).max(4_096).describe("Opaque cursor returned by a previous read."),
mode: z.literal("continue"),
}),
]);

export const workspaceReadItemsInputSchema = z.object({
requests: z
.array(workspaceContentReadRequestSchema)
.min(1)
.max(20)
.describe("Ordered workspace content reads."),
});

const workspaceReadPagesSchema = z.object({
requested: z.string().describe("Requested page range."),
returned: z.array(z.number().int().min(1)).describe("Page numbers included in content."),
total: z.number().int().min(1).describe("Total pages available."),
});

const workspaceReadRelationsSchema = z.array(
z.object({
direction: z.enum(["incoming", "outgoing"]),
kind: workspaceRelationKindSchema,
note: z.string().optional(),
path: workspacePathSchema,
}),
);

const workspaceContentReadResultSchema = z.union([
z.object({
content: z.string(),
format: z.literal("markdown"),
location: z.object({
endLine: z.number().int().nonnegative(),
kind: z.literal("lines"),
startLine: z.number().int().nonnegative(),
totalLines: z.number().int().nonnegative(),
}),
nextCursor: z.string().optional(),
path: workspacePathSchema,
relations: workspaceReadRelationsSchema.optional(),
status: z.literal("ready"),
type: z.literal("document"),
}),
z.object({
content: z.string(),
format: z.literal("markdown"),
location: workspaceReadPagesSchema.extend({ kind: z.literal("pages") }),
nextCursor: z.string().optional(),
path: workspacePathSchema,
relations: workspaceReadRelationsSchema.optional(),
status: z.literal("ready"),
type: z.literal("file"),
}),
z.object({
path: workspacePathSchema,
status: z.literal("pending"),
type: z.literal("file"),
}),
z.object({
code: z.enum(readWorkspaceItemsFailureCodes),
path: workspacePathSchema,
status: z.literal("failed"),
type: z.literal("file").optional(),
}),
]);

export const workspaceReadItemsOutputSchema = z.object({
results: z.array(workspaceContentReadResultSchema),
});

export type WorkspaceContentReadRequest = z.output<typeof workspaceContentReadRequestSchema>;
export type WorkspaceContentReadResult = z.output<typeof workspaceContentReadResultSchema>;
37 changes: 37 additions & 0 deletions src/features/workspaces/content/workspace-content-cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { z } from "zod";

import { decodeBase64UrlText, encodeBase64UrlText } from "#/lib/binary";

const workspaceContentCursorSchema = z.discriminatedUnion("kind", [
z.object({
itemId: z.string().min(1),
kind: z.literal("document"),
offset: z.number().int().nonnegative(),
revision: z.string().min(1),
version: z.literal(1),
}),
z.object({
itemId: z.string().min(1),
kind: z.literal("file"),
nextPage: z.number().int().positive(),
sourceHash: z.string().min(1),
version: z.literal(1),
}),
]);

type WorkspaceContentCursor = z.infer<typeof workspaceContentCursorSchema>;

export function encodeWorkspaceContentCursor(cursor: WorkspaceContentCursor) {
return encodeBase64UrlText(JSON.stringify(cursor));
}

export function decodeWorkspaceContentCursor(value: string): WorkspaceContentCursor | null {
if (value.length > 4_096) {
return null;
}
try {
return workspaceContentCursorSchema.parse(JSON.parse(decodeBase64UrlText(value)));
} catch {
return null;
}
}
Loading
Loading