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
30 changes: 18 additions & 12 deletions FORK.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.connectionProbe).toBe(true);
expect(second.capabilities.attachmentUploads).toBe(true);
expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
expect(second.capabilities.j5Squadrons).toBe(true);
expect(second.capabilities.j5HumanInbox).toBe(true);
expect(second.capabilities.pullRequests).toBe(true);
expect(second.capabilities.usagePriceOverrides).toBe(true);
expect(second.capabilities.threadTitleRegeneration).toBe(true);
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ export const make = Effect.gen(function* () {
serverVersion: packageJson.version,
orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION,
capabilities: {
j5Squadrons: true,
j5HumanInbox: true,
repositoryIdentity: true,
connectionProbe: true,
attachmentUploads: true,
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/j5/a2a/HumanInboxHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,49 @@ import { A2AHumanInbox } from "./HumanInboxService.ts";
import { A2AParticipantNotFoundError } from "./SendService.ts";
import { ExchangeId, LedgerMessageId, ParticipantId } from "./contracts.ts";

it("allows read-only connections to read the inbox but rejects answers", async () => {
const auth = Layer.mock(EnvironmentAuth.EnvironmentAuth)({
authenticateHttpRequest: () =>
Effect.succeed({
sessionId: AuthSessionId.make("auth-session:readonly-inbox"),
subject: "read-only-test",
method: "bearer-access-token",
scopes: [AuthOrchestrationReadScope],
}),
});
const routes = humanInboxHttpRouteLayer.pipe(
Layer.provide(
Layer.mock(A2AHumanInbox)({
resolvePersonId: () => Effect.succeed(ParticipantId.make("human:remote")),
list: () => Effect.succeed([]),
answer: () => Effect.die("Read-only answer reached the service"),
}),
),
Layer.provide(Layer.mock(A2ADeliveryWorker)({ notify: Effect.void })),
Layer.provideMerge(auth),
Layer.provide(HttpServer.layerServices),
);
const { dispose, handler } = HttpRouter.toWebHandler(routes, { disableLogger: true });
try {
assert.equal((await handler(new Request("http://remote.test/api/j5/a2a/inbox"))).status, 200);
const answered = await handler(
new Request("http://remote.test/api/j5/a2a/inbox/answer", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
personId: "human:remote",
exchangeId: "ask:1",
message: "Yes",
clientRequestId: "reply:1",
}),
}),
);
assert.equal(answered.status, 403);
} finally {
await dispose();
}
});

it("returns the resolved person above an empty inbox and preserves explicit selection", async () => {
const localPersonId = ParticipantId.make("human:local-operator");
const explicitPersonId = ParticipantId.make("human:second-person");
Expand Down
29 changes: 15 additions & 14 deletions apps/server/src/j5/a2a/HumanInboxHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,16 @@ import {
import { A2ADeliveryWorker } from "./DeliveryWorker.ts";
import { A2AHumanInbox } from "./HumanInboxService.ts";
import { CommCommandId, ExchangeId, ParticipantId } from "./contracts.ts";
import {
AnswerHumanExchangeRequest,
J5_API_PATHS,
type HumanInboxResponse,
type AnswerHumanExchangeResponse,
} from "@t3tools/contracts/j5";

const INBOX_PATH = "/api/j5/a2a/inbox";
const ANSWER_PATH = "/api/j5/a2a/inbox/answer";

const AnswerRequest = Schema.Struct({
personId: ParticipantId,
exchangeId: ExchangeId,
message: Schema.String.check(Schema.isNonEmpty()),
clientRequestId: Schema.String.check(Schema.isNonEmpty()),
});
const decodeAnswerRequest = Schema.decodeUnknownEffect(AnswerRequest);
const INBOX_PATH = J5_API_PATHS.inbox;
const ANSWER_PATH = J5_API_PATHS.answer;
const decodeAnswerRequest = Schema.decodeUnknownEffect(AnswerHumanExchangeRequest);

const authenticate = (
scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope,
Expand Down Expand Up @@ -103,7 +102,7 @@ export const humanInboxHttpRouteLayer = Layer.unwrap(
}),
);
return Result.isSuccess(result)
? HttpServerResponse.jsonUnsafe(result.success)
? HttpServerResponse.jsonUnsafe(result.success satisfies HumanInboxResponse)
: operationFailure(result.failure);
}).pipe(
Effect.catchTags({
Expand Down Expand Up @@ -135,15 +134,17 @@ export const humanInboxHttpRouteLayer = Layer.unwrap(
commandId: CommCommandId.make(
`command:j5:a2a:human:${encodeURIComponent(decoded.success.personId)}:${encodeURIComponent(decoded.success.exchangeId)}:${encodeURIComponent(decoded.success.clientRequestId)}`,
),
personId: decoded.success.personId,
exchangeId: decoded.success.exchangeId,
personId: ParticipantId.make(decoded.success.personId),
exchangeId: ExchangeId.make(decoded.success.exchangeId),
message: decoded.success.message,
acceptedAt,
})
.pipe(Effect.tap(() => worker.notify)),
);
return Result.isSuccess(result)
? HttpServerResponse.jsonUnsafe({ result: result.success })
? HttpServerResponse.jsonUnsafe({
result: result.success,
} satisfies typeof AnswerHumanExchangeResponse.Type)
: operationFailure(result.failure);
}).pipe(
Effect.catchTags({
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/j5/a2a/SquadronHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,42 @@ import { SquadronId } from "./contracts.ts";
const projectId = ProjectId.make("project:squadron-http");
const squadronId = SquadronId.make("squadron:squadron-http");

it("allows read-only connections to list Squadrons but rejects creation", async () => {
const auth = Layer.mock(EnvironmentAuth.EnvironmentAuth)({
authenticateHttpRequest: () =>
Effect.succeed({
sessionId: AuthSessionId.make("auth-session:readonly-squadrons"),
subject: "read-only-test",
method: "bearer-access-token",
scopes: [AuthOrchestrationReadScope],
}),
});
const routes = squadronHttpRouteLayer.pipe(
Layer.provide(
Layer.mock(SquadronManagementService)({
list: () => Effect.succeed([]),
create: () => Effect.die("Read-only creation reached the service"),
}),
),
Layer.provideMerge(auth),
Layer.provide(HttpServer.layerServices),
);
const { dispose, handler } = HttpRouter.toWebHandler(routes, { disableLogger: true });
try {
assert.equal((await handler(new Request("http://remote.test/api/j5/squadrons"))).status, 200);
const created = await handler(
new Request("http://remote.test/api/j5/squadrons", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Operations", projectId }),
}),
);
assert.equal(created.status, 403);
} finally {
await dispose();
}
});

it("lists and creates explicit Squadron project references", async () => {
const createInputs: Array<{ readonly name: string; readonly projectId: ProjectId }> = [];
const management = Layer.mock(SquadronManagementService)({
Expand Down
26 changes: 14 additions & 12 deletions apps/server/src/j5/a2a/SquadronHttp.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope } from "@t3tools/contracts";
import {
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
ProjectId,
} from "@t3tools/contracts";
CreateSquadronRequest,
J5_API_PATHS,
type SquadronListResponse,
type CreateSquadronResponse,
} from "@t3tools/contracts/j5";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Result from "effect/Result";
Expand All @@ -23,12 +25,7 @@ import {
} from "../../auth/http.ts";
import { SquadronManagementService } from "./SquadronManagementService.ts";

const SQUADRONS_PATH = "/api/j5/squadrons";

const CreateSquadronRequest = Schema.Struct({
name: Schema.String,
projectId: ProjectId,
});
const SQUADRONS_PATH = J5_API_PATHS.squadrons;
const decodeCreateSquadronRequest = Schema.decodeUnknownEffect(CreateSquadronRequest);

const authenticate = (
Expand Down Expand Up @@ -95,7 +92,9 @@ export const squadronHttpRouteLayer = Layer.unwrap(
yield* authenticate(AuthOrchestrationReadScope);
const result = yield* Effect.result(management.list());
if (Result.isSuccess(result)) {
return HttpServerResponse.jsonUnsafe({ squadrons: result.success });
return HttpServerResponse.jsonUnsafe({
squadrons: result.success,
} satisfies typeof SquadronListResponse.Type);
}
return yield* operationFailure(result.failure);
}).pipe(
Expand All @@ -121,7 +120,10 @@ export const squadronHttpRouteLayer = Layer.unwrap(
}
const result = yield* Effect.result(management.create(decoded.success));
if (Result.isSuccess(result)) {
return HttpServerResponse.jsonUnsafe({ squadron: result.success }, { status: 201 });
return HttpServerResponse.jsonUnsafe(
{ squadron: result.success } satisfies typeof CreateSquadronResponse.Type,
{ status: 201 },
);
}
return yield* operationFailure(result.failure);
}).pipe(
Expand Down
29 changes: 19 additions & 10 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1843,7 +1843,9 @@ export default function ChatView(props: ChatViewProps) {
[draftThread, fallbackDraftProject?.defaultModelSelection, threadId],
);
const isServerThread = serverThread !== null;
const activeThreadHomes = useThreadHomes(serverThread === null ? [] : [serverThread.id]);
const activeThreadHomes = useThreadHomes(
serverThread === null ? [] : [scopeThreadRef(environmentId, serverThread.id)],
);
const activeThread = isServerThread ? serverThread : localDraftThread;
const serverLatestRun = useMemo(
() => (serverProjection === null ? null : deriveLatestThreadRun(serverProjection)),
Expand Down Expand Up @@ -2140,18 +2142,24 @@ export default function ChatView(props: ChatViewProps) {
);
const activeProject = useProject(activeProjectRef);
const { status: squadronDirectoryStatus, squadrons } = useSquadronDirectory();
const ambientSquadronId = useSquadronAmbientScope();
const ambientSquadronScope = useSquadronAmbientScope();
const ambientSquadronId =
ambientSquadronScope?.environmentId === environmentId ? ambientSquadronScope.squadronId : null;
const draftSquadron = useSquadronDraftScope(routeThreadKey);
const activeThreadHome =
serverThread === null ? undefined : activeThreadHomes.get(serverThread.id);
serverThread === null
? undefined
: activeThreadHomes.get(scopedThreadKey(scopeThreadRef(environmentId, serverThread.id)));
const durableSquadronHome = activeThreadHome?.kind === "known" ? activeThreadHome.squadron : null;
const effectiveSquadronId = resolveEffectiveSquadronId({
durableHome: durableSquadronHome,
draftSquadronId: draftSquadron.squadronId,
ambientSquadronId,
});
const effectiveSquadronName =
squadrons.find(({ squadron }) => squadron.id === effectiveSquadronId)?.squadron.name ?? null;
squadrons.find(
(entry) => entry.environmentId === environmentId && entry.squadron.id === effectiveSquadronId,
)?.squadron.name ?? null;
const isFirstMessageForActiveThread = !isServerThread || activeMessageCount === 0;
const squadronDraftChip = resolveSquadronDraftChipState({
durableHome: durableSquadronHome,
Expand All @@ -2164,18 +2172,17 @@ export default function ChatView(props: ChatViewProps) {
buildSquadronPickerEntries({
squadrons,
projects: allProjects,
primaryEnvironmentId: primaryEnvironment?.environmentId ?? null,
}),
[allProjects, primaryEnvironment?.environmentId, squadrons],
[allProjects, squadrons],
);
const newThreadDestination = useMemo(
() =>
resolveCurrentThreadNewThreadDestination(
durableSquadronHome?.id ?? null,
durableSquadronHome === null ? null : { environmentId, squadronId: durableSquadronHome.id },
squadronDirectoryStatus,
squadronPickerEntries,
),
[durableSquadronHome?.id, squadronDirectoryStatus, squadronPickerEntries],
[environmentId, durableSquadronHome?.id, squadronDirectoryStatus, squadronPickerEntries],
);
const handleNewThreadInActiveProject = useCallback(() => {
if (newThreadDestination.kind === "picker") {
Expand Down Expand Up @@ -7317,7 +7324,8 @@ export default function ChatView(props: ChatViewProps) {
releaseDraftAttachments(composerAttachmentsSnapshot);
}
acknowledgeActiveThreadWoke();
if (squadronIdForLaunch !== undefined) refreshThreadHomes([threadIdForSend]);
if (squadronIdForLaunch !== undefined)
refreshThreadHomes([scopeThreadRef(environmentId, threadIdForSend)]);
}
}

Expand Down Expand Up @@ -8534,7 +8542,8 @@ export default function ChatView(props: ChatViewProps) {
{squadronDraftChip.visible ? (
<div className="flex px-3 pt-2">
<SquadronDraftChip
ambientSquadronId={ambientSquadronId}
ambientSquadronScope={ambientSquadronScope}
environmentId={environmentId}
draftKey={routeThreadKey}
draftId={draftId}
durableHome={durableSquadronHome}
Expand Down
20 changes: 15 additions & 5 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { describe, expect, it, vi } from "vite-plus/test";
import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import type { Thread } from "../types";
Expand Down Expand Up @@ -150,10 +151,13 @@ describe("resolveSquadronPickerDestination", () => {
updatedAt: "2026-08-31T10:00:00.000Z",
});
const destination = resolveSquadronPickerDestination({
squadronId: "squadron:bravo",
squadron: { environmentId: alpha.environmentId, squadronId: "squadron:bravo" },
threads: [alpha],
homesByThreadId: new Map([
[alpha.id, { kind: "known" as const, squadron: { id: "squadron:alpha" } }],
[
scopedThreadKey(scopeThreadRef(alpha.environmentId, alpha.id)),
{ kind: "known" as const, squadron: { id: "squadron:alpha" } },
],
]),
sortOrder: "updated_at",
});
Expand All @@ -173,11 +177,17 @@ describe("resolveSquadronPickerDestination", () => {
updatedAt: "2026-08-31T09:00:00.000Z",
});
const destination = resolveSquadronPickerDestination({
squadronId: "squadron:bravo",
squadron: { environmentId: alpha.environmentId, squadronId: "squadron:bravo" },
threads: [alpha, bravo],
homesByThreadId: new Map([
[alpha.id, { kind: "known" as const, squadron: { id: "squadron:alpha" } }],
[bravo.id, { kind: "known" as const, squadron: { id: "squadron:bravo" } }],
[
scopedThreadKey(scopeThreadRef(alpha.environmentId, alpha.id)),
{ kind: "known" as const, squadron: { id: "squadron:alpha" } },
],
[
scopedThreadKey(scopeThreadRef(bravo.environmentId, bravo.id)),
{ kind: "known" as const, squadron: { id: "squadron:bravo" } },
],
]),
sortOrder: "updated_at",
});
Expand Down
18 changes: 14 additions & 4 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { ThreadId, type EnvironmentId } from "@t3tools/contracts";
import type { ScopedSquadronRef } from "@t3tools/contracts/j5";
import {
type FilesystemBrowseEntry,
type KeybindingCommand,
Expand Down Expand Up @@ -390,9 +393,13 @@ export function filterCommandPaletteGroups(input: {
* navigation is keyed exclusively by the immutable Registrar home.
*/
export function resolveSquadronPickerDestination<
T extends { readonly id: string; readonly archivedAt: string | null } & ThreadSortInput,
T extends {
readonly id: string;
readonly environmentId: EnvironmentId;
readonly archivedAt: string | null;
} & ThreadSortInput,
>(input: {
readonly squadronId: string;
readonly squadron: ScopedSquadronRef;
readonly threads: ReadonlyArray<T>;
readonly homesByThreadId: ReadonlyMap<
string,
Expand All @@ -403,11 +410,14 @@ export function resolveSquadronPickerDestination<
}): { readonly kind: "navigate"; readonly thread: T } | { readonly kind: "create-draft" } {
const match = sortThreads(
input.threads.filter((thread) => {
const home = input.homesByThreadId.get(thread.id);
const home = input.homesByThreadId.get(
scopedThreadKey(scopeThreadRef(thread.environmentId, ThreadId.make(thread.id))),
);
return (
thread.archivedAt === null &&
home?.kind === "known" &&
home.squadron.id === input.squadronId
thread.environmentId === input.squadron.environmentId &&
home.squadron.id === input.squadron.squadronId
);
}),
input.sortOrder,
Expand Down
Loading
Loading