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
11 changes: 11 additions & 0 deletions apps/hub/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ import { requestErrorMetadata } from "./request-error.ts";
import { createLiveAppProxy } from "./live-app/proxy.ts";
import { createLiveAppRunner } from "./live-app/runner.ts";
import { mountWebUi } from "./web-ui.ts";
import {
coordinatorAskReader,
createDeclineNote,
} from "./projects/decline-note.ts";
import { version } from "../package.json";

const log = getLogger(["corbits-builder", "hub"]);
Expand Down Expand Up @@ -460,6 +464,13 @@ export async function createHubRuntime(
repoStore: agentRepoStore.repoStore,
}),
);
// Only a signed-in member of the tenant reaches the note; Interchange's route reuses the
// resolved tenant.
app.use(
"/api/tenants/:tenantId/approvals/:approvalId/reject",
createResolveTenant({ db: handle.db }),
createDeclineNote({ coordinatorAsk: coordinatorAskReader(handle.db) }),
);
Comment thread
TheGreatAxios marked this conversation as resolved.
Resolved
mountHubRoutes(app, {
db: handle.db,
sidecarRouter,
Expand Down
23 changes: 23 additions & 0 deletions apps/hub/src/config/decline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Interchange answers a declined call with "denied by approver: " and the decline's message. The
// hub writes that message for the coordinator's asks, so it reads the person's answer rather than
// a failed call. Stored declines keep this wording, and the conversation finds the person's words
// by it, so rewording it shows earlier declines' whole note as the person's message.
const DECLINE_LEAD = "The person has chosen to decline this tool call.";
const FEEDBACK_LEAD = DECLINE_LEAD + "\n\nTheir feedback is: ";
const NO_FEEDBACK =
DECLINE_LEAD +
" They left no feedback. Ask them what they would like to do next.";

/** The message a coordinator ask's decline carries, around the person's words when they gave some. */
export function declineNote(words: string | undefined): string {
const said = words?.trim();
return said === undefined || said === "" ? NO_FEEDBACK : FEEDBACK_LEAD + said;
}

/** The person's own words in a decline's message: none for a bare decline, all of a message the hub did not write. */
export function declinedWords(message: string | undefined): string | undefined {
if (message === undefined || message === NO_FEEDBACK) return undefined;
return message.startsWith(FEEDBACK_LEAD)
? message.slice(FEEDBACK_LEAD.length)
: message;
}
30 changes: 30 additions & 0 deletions apps/hub/src/mail/conversation-turns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "bun";
import { declineNote } from "../config/decline.ts";
import { resumeMessage } from "../config/resume.ts";
import { specialistEndedMessage } from "../config/specialist-ended.ts";
import { readDurableConversationTurns } from "./conversation-turns.ts";
Expand Down Expand Up @@ -105,6 +106,35 @@ test("resume turns read each approval decision as a plain line", async () => {
]);
});

test("a coordinator ask's decline reads back as the person's words alone", async () => {
const dir = await runRepo([
{
from: "signal@local",
text: JSON.stringify({
outcome: "rejected",
message: declineNote("Make it shorter\n\nand warmer"),
}),
},
{
from: "signal@local",
text: JSON.stringify({
outcome: "rejected",
message: declineNote(undefined),
}),
},
]);
expect(await personTexts(dir, "note")).toEqual([
{ role: "person", text: "Make it shorter\n\nand warmer" },
]);
expect(await personTexts(dir, "decision")).toEqual([
{
role: "person",
text: "Rejected a request: Make it shorter\n\nand warmer",
},
{ role: "person", text: "Rejected a request." },
]);
});

test("a person's words read back with the line breaks they typed", async () => {
const typed =
'I dont get this one bit\n\nOn "Constraints" v1:\n\n> Use $5 per month';
Expand Down
9 changes: 7 additions & 2 deletions apps/hub/src/mail/conversation-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
withoutResumeContext,
} from "../config/resume.ts";
import { personTurnText } from "../config/specialist-ended.ts";
import { declinedWords } from "../config/decline.ts";
import { readCoordinatorMailThreads } from "./conversation-threads.ts";
import { withCommittedAgentState } from "./committed-agent-state.ts";
import type { CoordinatorRepoDirSource } from "./coordinator-question.ts";
Expand Down Expand Up @@ -78,9 +79,13 @@ function approvalText(
): string | undefined {
const decision = ApprovalDecision(parseJson(body));
if (decision instanceof type.errors) return undefined;
const words = declinedWords(decision.message);
return decisions === "note"
? decision.message
: approvalDecisionLine(decision);
? words
: approvalDecisionLine({
outcome: decision.outcome,
...(words === undefined ? {} : { message: words }),
});
}

// A missing or corrupt turn list is empty; it does not throw.
Expand Down
52 changes: 52 additions & 0 deletions apps/hub/src/projects/decline-note.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { expect, test } from "bun:test";
import { Hono } from "hono";
import { declineNote } from "../config/decline.ts";
import { createDeclineNote } from "./decline-note.ts";

// What Interchange's reject route would receive.
async function rejected(approvalId: string, body: unknown) {
const app = new Hono();
app.use(
"/api/tenants/:tenantId/approvals/:approvalId/reject",
createDeclineNote({
coordinatorAsk: async (tenantId, id) =>
tenantId === "tenant_1" && id === "appr_brief",
}),
);
app.post("/api/tenants/:tenantId/approvals/:approvalId/reject", async (c) =>
c.json(await c.req.json()),
);
const response = await app.request(
`/api/tenants/tenant_1/approvals/${approvalId}/reject`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
return response.json();
}

test("a coordinator ask's decline carries the person's words in the hub's note", async () => {
expect(
await rejected("appr_brief", { scope: "once", message: "Make it shorter" }),
).toEqual({ scope: "once", message: declineNote("Make it shorter") });
});

test("a coordinator ask's decline without words asks the coordinator to find out what next", async () => {
expect(await rejected("appr_brief", {})).toEqual({
message: declineNote(undefined),
});
});

test("a decline of another run's ask passes through unchanged", async () => {
expect(await rejected("appr_packages", { message: "Not that one" })).toEqual({
message: "Not that one",
});
});

test("a decline whose message Interchange would refuse passes through unchanged", async () => {
expect(await rejected("appr_brief", { message: 42 })).toEqual({
message: 42,
});
});
69 changes: 69 additions & 0 deletions apps/hub/src/projects/decline-note.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { and, eq } from "drizzle-orm";
import type { MiddlewareHandler } from "hono";
import type { DB } from "@intx/db";
import { approval } from "@intx/db/schema";
import { getLogger } from "@intx/log";
import { declineNote } from "../config/decline.ts";
import { projectCoordinator } from "../db/schema.ts";
import { requestErrorMetadata } from "../request-error.ts";
import { readJson } from "../lib/json.ts";
import { isRecord } from "../mail/conversation-turns.ts";

const log = getLogger(["corbits-builder", "hub", "decline-note"]);

/** Whether an approval is one of a project coordinator's own asks. */
export function coordinatorAskReader(db: DB["db"]) {
return async (tenantId: string, approvalId: string): Promise<boolean> => {
const [asked] = await db
.select({ runId: approval.runId })
.from(approval)
.innerJoin(
projectCoordinator,
eq(projectCoordinator.runId, approval.runId),
)
.where(and(eq(approval.id, approvalId), eq(approval.tenantId, tenantId)))
.limit(1);
return asked !== undefined;
};
}

/**
* A decline of one of a coordinator's asks carries the hub's note around the person's words, so
* the coordinator reads it as their answer. Other declines, such as the coder's package asks, and
* a body Interchange's own validation should refuse, pass through unchanged.
*/
export function createDeclineNote(deps: {
coordinatorAsk: (tenantId: string, approvalId: string) => Promise<boolean>;
}): MiddlewareHandler {
return async (c, next) => {
const tenantId = c.req.param("tenantId");
const approvalId = c.req.param("approvalId");
if (c.req.method !== "POST" || !tenantId || !approvalId) return next();
// A note that cannot be written must not cost the person their decision.
let body: Record<string, unknown>;
let words: string | undefined;
try {
const read = await readJson(c.req.raw.clone());
if (!isRecord(read)) return next();
const message = read["message"];
if (message !== undefined && typeof message !== "string") return next();
if (!(await deps.coordinatorAsk(tenantId, approvalId))) return next();
body = read;
words = message;
} catch (error) {
log.error("Decline note unavailable", {
approvalId,
...requestErrorMetadata(error),
});
return next();
}
const headers = new Headers(c.req.raw.headers);
headers.delete("content-length");
c.req.raw = new Request(c.req.raw, {
method: "POST",
headers,
body: JSON.stringify({ ...body, message: declineNote(words) }),
});
return next();
};
}
5 changes: 2 additions & 3 deletions apps/web/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,9 +776,8 @@ export const wording = {
approve: "Yes, go ahead",
notYet: "Not yet",
decline: "Decline",
/** The composer's placeholder while an approval is open: a message leaves the card open. */
composerPlaceholder:
"Ask a question, or choose Not yet to request changes…",
/** The composer's placeholder while an approval is open: a message declines it, with these words as the reason. */
composerPlaceholder: "Say what you'd like changed, or ask a question…",
/** A whole message that approves the one open card, after a countdown the person can undo. */
typedYes: {
affirmations: [
Expand Down
Loading