Skip to content
Closed
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: 2 additions & 0 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseThreadContextHref } from "@t3tools/shared/threadContext";
import {
fileBasename,
formatFilePathPosition,
Expand Down Expand Up @@ -265,6 +266,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {

export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation {
const normalized = normalizeMarkdownLinkDestination(href);
if (parseThreadContextHref(normalized)) return { kind: "link", href: normalized };
try {
const parsed = new URL(normalizeNativeMarkdownUrl(normalized));
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseThreadContextHref } from "@t3tools/shared/threadContext";
import { ThreadContextDivider } from "./thread-context-divider";
import { ThreadHandoffRow } from "./thread-handoff-row";
import {
Expand Down Expand Up @@ -2164,6 +2165,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const userBubbleColor = theme["--color-user-bubble"];
const onMarkdownLinkPress = useCallback(
(href: string) => {
const linkedThread = parseThreadContextHref(href);
if (linkedThread) {
navigation.navigate("Thread", linkedThread);
return;
}
const presentation = resolveMarkdownLinkPresentation(href);
if (presentation.kind === "file") {
const relativePath = resolveWorkspaceRelativeFilePath(
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/lib/markdownLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,12 @@ describe("resolveMarkdownLinkPresentation", () => {
});
});
});

it("keeps valid thread links available to in-app navigation", () => {
const href = "t3-thread://v1/environment-1/thread-1";
expect(resolveMarkdownLinkPresentation(href)).toEqual({ kind: "link", href });
expect(resolveMarkdownLinkPresentation("t3-thread://v1/environment-1/%ZZ")).toEqual({
kind: "link",
href: null,
});
});
10 changes: 3 additions & 7 deletions apps/mobile/src/lib/threadActivityInspector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import * as DateTime from "effect/DateTime";
import { describe, expect, it } from "vite-plus/test";

import { buildThreadFeed, type ThreadFeedActivity } from "./threadActivity";
import type { ThreadFeedActivity } from "./threadActivity";
import { buildThreadActivityInspector } from "./threadActivityInspector";

const threadId = ThreadId.make("thread-1");
Expand Down Expand Up @@ -43,19 +43,15 @@ function itemBase(id: string) {
};
}

function activityFor(item: OrchestrationV2TurnItem): ThreadFeedActivity {
function activityFor(item: OrchestrationV2TurnItem): Pick<ThreadFeedActivity, "projectedItem"> {
const row: OrchestrationV2ProjectedTurnItem = {
position: 0,
visibility: "inherited",
sourceThreadId,
sourceItemId: item.id,
item,
};
const group = buildThreadFeed([row])[0];
if (group?.type !== "activity-group" || !group.activities[0]) {
throw new Error("Expected an activity group");
}
return group.activities[0];
return { projectedItem: row };
}

describe("buildThreadActivityInspector", () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/threadActivityInspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ function addBlock(
}

export function buildThreadActivityInspector(
activity: ThreadFeedActivity,
activity: Pick<ThreadFeedActivity, "projectedItem">,
support: V2ItemSupport,
currentThreadId: ThreadId,
): ThreadActivityInspectorModel {
Expand Down
193 changes: 192 additions & 1 deletion apps/server/src/mcp/OrchestratorMcpService.activity.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { formatThreadContextLink } from "@t3tools/shared/threadContext";
import {
EnvironmentId,
MessageId,
NodeId,
type OrchestrationV2ThreadProjection,
ProjectId,
Expand All @@ -17,7 +19,10 @@ import { expect, it } from "vite-plus/test";
import { ProviderAdapterRegistryV2 } from "../orchestration-v2/ProviderAdapterRegistry.ts";
import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts";
import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts";
import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts";
import {
ThreadManagementService,
ThreadManagementThreadNotFoundError,
} from "../orchestration-v2/ThreadManagementService.ts";
import type * as McpInvocationContext from "./McpInvocationContext.ts";
import {
layer as orchestratorMcpServiceLayer,
Expand Down Expand Up @@ -316,3 +321,189 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy
expect(result.childThreadId).toBe(childThreadId);
}).pipe(Effect.provide(layer), Effect.runPromise);
});

it("reads user-attached foreign threads in chunks without granting write access", async () => {
const text = "Full thread context. ".repeat(4000);
const target = {
thread: {
...baseThread({
threadId: childThreadId,
title: "Attached",
instanceId: parentInstanceId,
model: "gpt-5.4",
}),
projectId: ProjectId.make("other-project"),
},
runs: [],
runtimeRequests: [],
messages: [
{
id: "message-context",
role: "user",
text: "See [logs](t3-context://v1/terminal/logs)",
attachments: [
{
type: "file",
id: "attachment-1",
name: "notes.txt",
mimeType: "text/plain",
sizeBytes: 20,
},
],
context: {
version: 1,
records: [
{
version: 1,
kind: "terminal",
contextId: "logs",
label: "logs",
terminalId: "terminal-1",
terminalLabel: "Build",
lineStart: 1,
lineEnd: 1,
text: "Build passed",
},
],
},
},
],
contextTransfers: [],
subagents: [],
updatedAt: now,
visibleTurnItems: [
{
position: 0,
visibility: "local",
sourceThreadId: childThreadId,
sourceItemId: "item-1",
item: {
type: "assistant_message",
text,
messageId: "message-1",
runId: null,
status: "completed",
title: null,
updatedAt: now,
},
},
{
position: 1,
visibility: "local",
sourceThreadId: childThreadId,
sourceItemId: "item-2",
item: {
type: "user_message",
text: "See logs",
messageId: "message-context",
runId: null,
status: "completed",
title: null,
updatedAt: now,
},
},
],
} as unknown as OrchestrationV2ThreadProjection;
const link = formatThreadContextLink({ environmentId, threadId: childThreadId }, "Attached");
const parent = {
...target,
thread: baseThread({
threadId: parentThreadId,
title: "Parent",
instanceId: parentInstanceId,
model: "gpt-5.4",
}),
messages: [],
} as unknown as OrchestrationV2ThreadProjection;
let author: "agent" | "user" = "agent";
let attachedLink = link;
const layer = orchestratorMcpServiceLayer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.mock(ThreadManagementService)({
getThreadProjection: (threadId) =>
Effect.succeed(
threadId === childThreadId
? target
: {
...parent,
messages: [
{
id: MessageId.make("attached-message"),
threadId: parentThreadId,
runId: null,
nodeId: null,
role: "user",
createdBy: author,
creationSource: "web",
text: attachedLink,
attachments: [],
streaming: false,
createdAt: now,
updatedAt: now,
},
],
},
),
getProjectThread: ({ projectId, threadId }) =>
Effect.fail(new ThreadManagementThreadNotFoundError({ projectId, threadId })),
}),
Layer.mock(ProviderRegistry)({}),
Layer.mock(ScheduledTaskService)({}),
Layer.mock(ProviderAdapterRegistryV2)({}),
NodeCrypto.layer,
),
),
);
await Effect.gen(function* () {
const service = yield* OrchestratorMcpService;
const read = () =>
service.readThread(makeScope(), { threadId: childThreadId, limit: 1, maxCharsPerItem: 1000 });
expect((yield* Effect.result(read()))._tag).toBe("Failure");
author = "user";
attachedLink = formatThreadContextLink(
{ environmentId: EnvironmentId.make("other-environment"), threadId: childThreadId },
"Attached",
);
expect((yield* Effect.result(read()))._tag).toBe("Failure");
attachedLink = link;
const bounded = yield* service.readThread(makeScope(), { threadId: childThreadId });
expect(bounded.items[0]?.nextTextOffset).toBe(4_000);
expect(
(yield* Effect.result(
service.readThread(makeScope(), {
threadId: childThreadId,
environmentId: EnvironmentId.make("wrong-environment"),
}),
))._tag,
).toBe("Failure");
const first = yield* read();
expect(first.thread.projectId).toBe("other-project");
expect(first.items[0]?.nextTextOffset).toBe(1000);
let reconstructed = "";
let offset = 0;
do {
const page = yield* service.readThread(makeScope(), {
threadId: childThreadId,
itemPosition: 0,
textOffset: offset,
maxCharsPerItem: 1000,
});
const item = page.items[0]!;
reconstructed += item.text?.replace(/\n…\[truncated\]$/, "");
if (item.nextTextOffset == null) break;
offset = item.nextTextOffset;
} while (offset <= text.length);
expect(reconstructed).toBe(text);
const contextPage = yield* service.readThread(makeScope(), {
threadId: childThreadId,
itemPosition: 1,
});
expect(contextPage.items[0]?.text).toContain("1 | Build passed");
expect(contextPage.items[0]?.text).toContain("notes.txt");
const write = yield* Effect.result(
service.sendToThread(makeScope(), { threadId: childThreadId, message: "Do not send" }),
);
expect(write._tag).toBe("Failure");
}).pipe(Effect.provide(layer), Effect.runPromise);
});
Loading
Loading