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: 16 additions & 14 deletions AGENTS.md

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ verified results. Click for the full-resolution MP4.*
1. **Capture an idea** — save anything you may want to build, fix, investigate,
or improve as a Project Todo.
2. **Shape the work** — discuss the Todo with an Agent until the scope and
expected outcome are clear.
expected outcome are clear; generate or refine its single Markdown Plan when
the work needs one.
3. **Mark it Ready** — keep rough ideas separate from work that is ready to run.
4. **Start the work** — launch a fresh Lead Session or an Automation from the
Todo.
Expand Down Expand Up @@ -75,6 +76,7 @@ without mixing everything into one conversation.
| Agent | Responsibility | Model Profile |
|---|---|---|
| Lead | Owns the outcome, works directly, and coordinates other Agents | `principal` |
| Discussion | Shapes one Todo and its optional Plan without implementing it | `principal` |
| Analyst | Deep analysis, planning support, and review | `deep` |
| Build | Implementation and verification | `deep` or `fast` |
| Explore | Fast codebase investigation | `fast` |
Expand All @@ -90,8 +92,8 @@ ArchCode separates Agent responsibility from model choice. Configure
judgment matters and fast or local models for exploration and routine work.

Connect official AI SDK providers, custom OpenAI-compatible endpoints, or
Responses-compatible endpoints. A root Lead Session can also override its model
without changing the Agent's tools or responsibility. See [provider and model
Responses-compatible endpoints. A user-facing root Session can also override
its model without changing the Agent's tools or responsibility. See [provider and model
configuration](docs/configuration.md).

## Stay in control
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/routes/attachment-http-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function mapAttachmentHttpError(
if (error instanceof NotRootSessionError) {
return new ServerError(
"ATTACHMENT_INVALID",
`Session "${sessionId}" is not a root Lead Session`,
`Session "${sessionId}" is not a user-facing root Session`,
400,
);
}
Expand Down
66 changes: 66 additions & 0 deletions apps/server/src/routes/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,72 @@ describe("messages routes", () => {
expect(response.status).toBe(409);
});

test("maps code-identical command conflicts across hot-reload class identities", async () => {
const { app, project, runtime } = await createTestApp("command-conflict-reload");
(runtime.acceptSessionMessage as unknown as ReturnType<typeof mock>).mockImplementation(async () => {
throw Object.assign(new Error("commands require an idle root Session"), {
code: "SESSION_COMMAND_CONFLICT",
sessionId: "session-1",
});
});
const response = await app.request(`/api/projects/${project.slug}/sessions/session-1/messages`, {
method: "POST",
body: JSON.stringify({
text: "/skill use plan-work",
attachmentIds: [],
clientRequestId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
requestedModelSelection,
}),
headers: { "content-type": "application/json" },
});

expect(response.status).toBe(409);
expect((await response.json()).error.details).toEqual({
scopeCode: "SESSION_COMMAND_CONFLICT",
sessionId: "session-1",
});
});

test("maps an active HITL tool batch to a stable conflict", async () => {
const { app, project, runtime } = await createTestApp("active-hitl");
const activeHitlError = Object.assign(
new Error(
"Session \"session-1\" is waiting for a human-in-the-loop response: hitl-1.",
),
{
code: "SESSION_TOOL_BATCH_ACTIVE",
sessionId: "session-1",
hitlIds: ["hitl-1"],
},
);
(runtime.acceptSessionMessage as unknown as ReturnType<typeof mock>).mockImplementation(async () => {
throw activeHitlError;
});
const response = await app.request(`/api/projects/${project.slug}/sessions/session-1/messages`, {
method: "POST",
body: JSON.stringify({
text: "/skill use plan-work",
attachmentIds: [],
clientRequestId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
requestedModelSelection,
}),
headers: { "content-type": "application/json" },
});

expect(response.status).toBe(409);
expect(await response.json()).toEqual({
error: {
code: "BAD_REQUEST",
message: activeHitlError.message,
details: {
scopeCode: "SESSION_TOOL_BATCH_ACTIVE",
sessionId: "session-1",
hitlIds: ["hitl-1"],
},
},
});
});

test("returns a stable indeterminate command outcome without authorizing replay", async () => {
const { app, project, runtime } = await createTestApp("command-indeterminate");
(runtime.acceptSessionMessage as unknown as ReturnType<typeof mock>).mockImplementation(async () => {
Expand Down
82 changes: 72 additions & 10 deletions apps/server/src/routes/messages.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import {
SessionFamilyActiveError,
SessionFamilyStopInProgressError,
SessionCommandConflictError,
SessionCommandOutcomeError,
SessionInputConflictError,
SessionSteerUnavailableError,
SessionToolBatchActiveError,
type AgentRuntime,
} from "@archcode/agent-core";
import {
isSessionMessageUnavailableCode,
MAX_ATTACHMENTS_PER_MESSAGE,
type AttachmentDescriptor,
type SessionMessageUnavailableCode,
} from "@archcode/protocol";
import { Hono } from "hono";
import { z } from "zod/v4";
Expand Down Expand Up @@ -203,19 +208,76 @@ function mapMessageMutationError(error: unknown, sessionId: string): unknown {
...(error.current === undefined ? {} : { current: error.current }),
});
}
if (error instanceof SessionSteerUnavailableError || error instanceof SessionCommandConflictError) {
return new ServerError("BAD_REQUEST", error.message, 409, {
scopeCode: error.code,
sessionId: error.sessionId,
const unavailableCode = sessionMessageUnavailableCode(error);
if (unavailableCode !== undefined) {
return new ServerError("BAD_REQUEST", errorMessage(error), 409, {
scopeCode: unavailableCode,
sessionId: codedErrorValue(error, "sessionId") ?? sessionId,
...(codedErrorValue(error, "rootSessionId") === undefined
? {}
: { rootSessionId: codedErrorValue(error, "rootSessionId") }),
...(codedStringArray(error, "hitlIds") === undefined
? {}
: { hitlIds: codedStringArray(error, "hitlIds") }),
});
}
if (error instanceof SessionCommandOutcomeError) {
return new ServerError("BAD_REQUEST", error.message, 409, {
scopeCode: error.code,
sessionId: error.sessionId,
clientRequestId: error.clientRequestId,
status: error.status,
const errorCode = codedErrorValue(error, "code");
if (
error instanceof SessionSteerUnavailableError
|| error instanceof SessionCommandConflictError
|| errorCode === "SESSION_STEER_UNAVAILABLE"
|| errorCode === "SESSION_COMMAND_CONFLICT"
) {
return new ServerError("BAD_REQUEST", errorMessage(error), 409, {
scopeCode: errorCode,
sessionId: codedErrorValue(error, "sessionId") ?? sessionId,
});
}
if (
error instanceof SessionCommandOutcomeError
|| errorCode === "SESSION_COMMAND_FAILED"
|| errorCode === "SESSION_COMMAND_OUTCOME_INDETERMINATE"
) {
return new ServerError("BAD_REQUEST", errorMessage(error), 409, {
scopeCode: errorCode,
sessionId: codedErrorValue(error, "sessionId") ?? sessionId,
clientRequestId: codedErrorValue(error, "clientRequestId"),
status: codedErrorValue(error, "status"),
});
}
return error;
}

function sessionMessageUnavailableCode(
error: unknown,
): SessionMessageUnavailableCode | undefined {
const code = codedErrorValue(error, "code");
if (isSessionMessageUnavailableCode(code)) return code;
if (
error instanceof SessionFamilyActiveError
|| error instanceof SessionFamilyStopInProgressError
|| error instanceof SessionToolBatchActiveError
|| error instanceof SessionCommandConflictError
) {
return error.code;
}
return undefined;
}

function codedErrorValue(error: unknown, key: string): string | undefined {
if (typeof error !== "object" || error === null || !(key in error)) return undefined;
const value = Reflect.get(error, key);
return typeof value === "string" ? value : undefined;
}

function codedStringArray(error: unknown, key: string): readonly string[] | undefined {
if (typeof error !== "object" || error === null || !(key in error)) return undefined;
const value = Reflect.get(error, key);
return Array.isArray(value) && value.every((item) => typeof item === "string")
? value
: undefined;
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Session command could not be accepted";
}
6 changes: 3 additions & 3 deletions apps/server/src/routes/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ function createTestRuntime(projectRegistry: ProjectRegistry) {
const session = sessions.get(key);
if (!session) throw new MissingSessionFileError();
if (session.parentSessionId !== undefined || session.rootSessionId !== session.sessionId) {
throw new SessionModelSelectionNotAllowedError(session.sessionId, "not_root_lead");
throw new SessionModelSelectionNotAllowedError(session.sessionId, "not_user_facing_root");
}
const revision = (modelSelectionRevisions.get(key) ?? input.expectedRevision) + 1;
modelSelectionRevisions.set(key, revision);
Expand Down Expand Up @@ -559,11 +559,11 @@ describe("sessions routes", () => {
expect(await response.json()).toEqual({
error: {
code: "BAD_REQUEST",
message: expect.stringContaining("root Lead Session"),
message: expect.stringContaining("user-facing root Session"),
details: {
scopeCode: "SESSION_MODEL_SELECTION_NOT_ALLOWED",
sessionId: "child-session",
reason: "not_root_lead",
reason: "not_user_facing_root",
},
},
});
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/routes/todos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ describe("Project Todo routes", () => {
entry,
});
}

const planDiscussion = await fixture.app.request(base, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
expectedRevision: 1,
entry: "discussion",
initialIntent: "plan",
}),
});
expect(planDiscussion.status).toBe(201);
expect(fixture.createSession).toHaveBeenLastCalledWith(todo.id, {
expectedRevision: 1,
entry: "discussion",
initialIntent: "plan",
});
});

test("strictly validates flat mutation and Session request bodies", async () => {
Expand All @@ -103,10 +119,20 @@ describe("Project Todo routes", () => {
headers,
body: JSON.stringify({ expectedRevision: 1, entry: "invalid" }),
});
const invalidPlanIntent = await fixture.app.request(`${base}/${fixture.todo.id}/sessions`, {
method: "POST",
headers,
body: JSON.stringify({
expectedRevision: 1,
entry: "work",
initialIntent: "plan",
}),
});

expect(emptyMutation.status).toBe(400);
expect(mixedArchive.status).toBe(400);
expect(invalidEntry.status).toBe(400);
expect(invalidPlanIntent.status).toBe(400);
});

test("maps not-found and current-domain conflicts", async () => {
Expand Down
12 changes: 5 additions & 7 deletions apps/server/src/routes/todos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import {
PROJECT_TODO_REJECTION_REASON_MAX_LENGTH,
PROJECT_TODO_TITLE_MAX_LENGTH,
} from "@archcode/protocol";
import type { AgentRuntime } from "@archcode/agent-core";
import {
CreateProjectTodoSessionSchema,
type AgentRuntime,
} from "@archcode/agent-core";
import { z } from "zod/v4";

import { BadRequestError, ServerError } from "../errors";
Expand Down Expand Up @@ -44,11 +47,6 @@ const ProjectTodoUpdateBodySchema = z.strictObject({
context.addIssue({ code: "custom", path: ["archived"], message: "archived cannot be combined with other Todo fields" });
}
});
const CreateProjectTodoSessionBodySchema = z.strictObject({
expectedRevision: z.number().int().positive(),
entry: z.enum(["discussion", "work", "automation"]),
});

export interface ProjectTodoServiceLike {
listTodos(): Promise<readonly ProjectTodo[]>;
createTodo(input: ProjectTodoCreateInput): Promise<ProjectTodo>;
Expand Down Expand Up @@ -106,7 +104,7 @@ export function createTodosRoutes(runtime: AgentRuntime): Hono {
app.post(
"/:slug/todos/:todoId/sessions",
zValidator("param", ProjectTodoParamsSchema),
zValidator("json", CreateProjectTodoSessionBodySchema),
zValidator("json", CreateProjectTodoSessionSchema),
async (c) => {
const { slug, todoId } = c.req.valid("param");
const project = await resolveProject(runtime, slug);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/composite/ToolCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ describe("ToolCard strict result consumer", () => {
};
const element = ToolCard({ part, projectSlug: "demo", sessionId: "root-1" });
expect(textContent(element)).toContain("Result unknown");
expect(findByTestId(element, "tool-output-open")).toBeDefined();
expect(textContent(findByTestId(element, "tool-output-open"))).toBe("View output");
});

test("collapses to the summary row and expands through the one disclosure control", () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/composite/ToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export function ToolCard({ part, projectSlug, sessionId, grouped = false }: Tool
className="h-8 rounded-sm bg-brand-subtle px-3 text-[12px] font-medium text-brand transition-colors duration-[var(--motion-hover)] hover:bg-bg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
onClick={() => setViewerOpen((value) => !value)}
>
{viewerOpen ? "关闭输出" : "查看输出"}
{viewerOpen ? "Hide output" : "View output"}
</button>
<span className="ml-2 text-[11px] text-text-tertiary">expires {new Date(artifactRecovery.expiresAt).toLocaleString()}</span>
</div>
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/lib/agent-constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,19 @@ import {
describe("AGENT_TYPES", () => {
test("contains all expected agent types", () => {
expect(AGENT_TYPES).toEqual([
"lead", "analyst", "build", "explore", "librarian",
"lead", "discussion", "analyst", "build", "explore", "librarian",
]);
});

test("is a readonly tuple", () => {
expect(AGENT_TYPES.length).toBe(5);
expect(AGENT_TYPES.length).toBe(6);
});
});

describe("isValidAgentType", () => {
test("returns true for valid agent types", () => {
expect(isValidAgentType("lead")).toBe(true);
expect(isValidAgentType("discussion")).toBe(true);
expect(isValidAgentType("analyst")).toBe(true);
expect(isValidAgentType("build")).toBe(true);
expect(isValidAgentType("explore")).toBe(true);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/agent-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AgentDescriptor } from "@archcode/protocol";

export const AGENT_TYPES = [
"lead",
"discussion",
"analyst",
"build",
"explore",
Expand Down
Loading