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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ HARNESS=copilot-sdk
BACKGROUND_JOBS=on
WEB_RESEARCH=on

# Optional conversation-derived cards prototype; independent of AGENT.
CONVERSATION_PLAN=off
JEV_MODEL=jev-latest
# Per Jev request, in milliseconds (100–60000; default 30000).
JEV_TIMEOUT_MS=30000
# Required only when CONVERSATION_PLAN=on. Keep this key on the server.
JEV_API_KEY=

# GitHub App sign-in.
# Remote VM mode overrides this local origin in child processes; see docs/exe-dev.md.
APP_ORIGIN=http://127.0.0.1:8787
Expand Down
31 changes: 16 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,10 @@ external implementation runs are durable.
- **The dialect is an allowlist.** Document MDX is parsed and rendered, never
evaluated. Keep imports, exports, expressions, raw HTML, and unknown JSX out.
- **Records own decisions.** Question answers and accepted comment decisions live
in sidecar records. Their document components are projections. Planner
operations protect those projections, but browser CRDT validation does not
yet cross-check them against records; do not treat the projection as authority.
in sidecar records. Their document components are projections. Browser CRDT
validation allows unchanged projections to move but rejects their creation,
removal or alteration. Domain operations update records and projections together;
do not treat the projection as authority.
- **Admission is not authorization.** Optional user and organization lists admit
an identity. Browser routes, sockets, and Planner tools separately recheck the
App installation and repository role.
Expand Down Expand Up @@ -139,13 +140,12 @@ replayed automatically because it may already have made durable tool changes.

## Questions, comments, and anchors

Question submission currently claims the shared draft with `claimSubmit()`,
projects the answer and authoritative record inside the room lock, calls
`Store.stage()`, persists the document or sidecar, and invokes the returned
finalizer. `stage()` removes the open draft before persistence, and the failure
path does not restore it after a storage error. Treat this as a known durability
gap: a proper two-phase refactor must retain or restore the draft until the
fenced commit succeeds.
Question submission claims the shared draft with `claimSubmit()`, then stages
the document, authoritative record, and copied question maps inside the room
lock. Publish the candidate only after its fenced commit and invoke the returned
`Store.stage()` finalizer afterward. A failed commit releases the claim and keeps
the accepted draft available for retry. Planner asks and cancellation follow the
same persistence-before-publication ordering.

A decision's definition is frozen except for appended options. Any writer may
send `question:option` to add one while the question is open (at most 10 options,
Expand All @@ -160,8 +160,9 @@ renders and derives.
Anchors combine Yjs relative positions with canonical block digests. A position
survives surrounding edits; a digest can recover one unique block after a move
or epoch replacement. Ambiguous matches must orphan rather than guess. The safe
ordering is to rebase against the old document before a server-authored edit;
the current Planner path reconciles first and is a known recovery gap.
ordering is to rebase against the old document before a server-authored edit.
Scoped decision-prose jobs follow that ordering. The ordinary `edit_plan` tool
still reconciles first and remains a recovery gap.

The browser starts a comment from a bounded quote locator, selected length,
offset hint, and block indices, not an unbounded copy of selected text. The
Expand Down Expand Up @@ -279,9 +280,9 @@ so merge ranges from all mounted editors before replacing a registry entry.
after `send()` loses edits on disconnect.
- Rebuilds and reconnects must replay unacknowledged updates only when the epoch
is still compatible.
- Idle eviction removes a room registry entry before its asynchronous close and
checkpoint completes. Until that lifecycle is serialized, avoid opening a
replacement room during close and test revision conflicts around eviction.
- Room opening and closing share the document lifecycle lock. Opening waits for
an active close; eviction forgets the room only after runtime shutdown and
persistence complete. Preserve that ordering.
- Sidecar restoration currently drops an invalid optional implementation graph
instead of rejecting the whole sidecar. Do not generalize that fail-open
behavior to other durable fields.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ and tool vocabulary remain optimized for planning.
credentials in an OS vault or an explicitly consented local file. Documents,
transcripts, decisions, research request staging, background-job inputs and
artifacts, and token-free session records are stored in PostgreSQL.
- Optional conversation-derived cards default to off. With `CONVERSATION_PLAN=on`,
current and recent Chat messages and selected decision context are sent to
TypeSafe's Jev service for interpretation. This uses the server's `JEV_API_KEY`,
separately from the Planner's harness credentials, and continues with `AGENT=off`.
- One Chopin process may write to a database at a time. Horizontal application
scaling and zero-downtime rolling deployment are not supported.

Expand Down
215 changes: 215 additions & 0 deletions apps/server/src/conversation-plan/cards-domain-agreement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { describe, expect, test } from "bun:test";
import type { Chat, ConversationPlan } from "@chopin/protocol";
import { applyInference, replay, restoreState } from "./domain";
import { applyEvent } from "./events";
import {
base,
julesAgrees,
mina,
optionId,
source,
withOption,
withPendingSettle,
} from "./cards-domain.test-fixtures";

describe("settle agreement authority", () => {
test("another member can agree with the pending option without deciding it", () => {
let state = applyInference(withPendingSettle(), {
...base("agree", 3),
type: "settle.agreed",
source: source(julesAgrees, "support"),
optionId,
}, julesAgrees);
expect(state.events.at(-1)?.type).toBe("settle.agreed");
expect(state.threads[0]).toMatchObject({
status: "exploring",
version: 4,
pendingSettle: { optionId, proposer: "mina" },
});
expect(state.threads[0].decision).toBeUndefined();
expect(replay(state.events).threads).toEqual(state.threads);
});

test("agreement requires another member and exactly the pending option", () => {
let self: Chat.Entry = {
...julesAgrees,
id: "m4",
author: { kind: "member", handle: "mina" },
};
expect(() =>
applyInference(withPendingSettle(), {
...base("self", 3),
type: "settle.agreed",
source: source(self, "support"),
optionId,
}, self)
).toThrow("agreement needs another member");
expect(() =>
applyInference(withPendingSettle(), {
...base("different", 3),
type: "settle.agreed",
source: source(julesAgrees, "support"),
optionId: "other-option",
}, julesAgrees)
).toThrow("agreement names another option");
expect(() =>
applyInference(withOption(), {
...base("no-proposal", 2),
type: "settle.agreed",
source: source(julesAgrees, "support"),
optionId,
}, julesAgrees)
).toThrow("no pending proposal to settle");
});

test("agreement stays classifier-sourced support by a real member", () => {
let state = withPendingSettle();
expect(() =>
applyInference(state, {
...base("human-agree", 3, "human"),
type: "settle.agreed",
source: source(julesAgrees, "support"),
optionId,
}, julesAgrees)
).toThrow("human events require authenticated correction");
expect(() =>
applyInference(state, {
...base("planner-agree", 3, "planner"),
type: "settle.agreed",
source: source(julesAgrees, "support"),
optionId,
}, julesAgrees)
).toThrow("agreement is inferred");
expect(() =>
applyInference(state, {
...base("objection-agree", 3),
type: "settle.agreed",
source: source(julesAgrees, "objection"),
optionId,
}, julesAgrees)
).toThrow("agreement source role disagrees");
let agent: Chat.Entry = {
...julesAgrees,
id: "agent-agree",
author: { kind: "agent" },
};
expect(() =>
applyInference(state, {
...base("agent-agree", 3),
type: "settle.agreed",
source: source(agent, "support"),
optionId,
}, agent)
).toThrow("agreement needs another member");
});
});

describe("decision card thread authority", () => {
test("a settle proposal records its human proposer without deciding", () => {
let state = applyInference(withOption(), {
...base("settle", 2),
type: "settle.suggested",
source: source(mina, "resolution"),
optionId,
}, mina);
expect(state.threads[0].pendingSettle).toEqual({
optionId,
proposer: "mina",
messageId: "m2",
});
expect(state.threads[0].status).toBe("exploring");
expect(state.threads[0].decision).toBeUndefined();
expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual(state.threads);
});

test("a spike agreement binds the current accepted proposal without deciding", () => {
let cardId = "01K0N4TR8K7JGM4R1J7PW4R8YJ";
let state = withOption();
state = applyEvent(state, {
...base("link-spike", state.threads[0]!.version),
type: "card.linked",
questionnaireId: cardId,
});
let mei: Chat.Entry = {
id: "mei-spike",
author: { kind: "member", handle: "mei" },
text: "I'd pick GitHub Apps for the spike;",
ts: 4,
};
let proposal: ConversationPlan.Event = {
...base("spike-proposal", state.threads[0]!.version),
type: "scoped-choice.proposed",
source: source(mei, "support"),
cardId,
optionId,
label: "GitHub Apps",
scope: "spike",
};
state = applyInference(state, proposal, mei);
let rob: Chat.Entry = {
id: "rob-spike",
author: { kind: "member", handle: "rob" },
text: "yep, GitHub Apps for the spike.",
ts: 5,
};
let agreement: ConversationPlan.Event = {
...base("spike-agreement", state.threads[0]!.version),
type: "scoped-choice.agreed",
source: source(rob, "support"),
proposalId: proposal.id,
cardId,
optionId,
label: "GitHub Apps",
scope: "spike",
};
expect(() => applyInference(state, { ...agreement, proposalId: "prior-proposal" }, rob))
.toThrow("scoped choice agreement does not match the current proposal");
let agreed = applyInference(state, agreement, rob);
expect(agreed.events.at(-1)).toMatchObject({
type: "scoped-choice.agreed",
proposalId: proposal.id,
});
expect(agreed.threads[0]?.pendingScopedChoice?.proposalId).toBe(proposal.id);
expect(agreed.threads[0]?.pendingSettle).toBeUndefined();
expect(agreed.threads[0]?.decision).toBeUndefined();
expect(restoreState(JSON.parse(JSON.stringify(agreed))).threads).toEqual(agreed.threads);
});

test("settle proposals need a known option and a member's resolution quote", () => {
let state = withOption();
let valid = {
...base("settle", 2),
type: "settle.suggested" as const,
source: source(mina, "resolution"),
optionId,
};
expect(() =>
applyInference(state, {
...valid,
optionId: "unknown",
}, mina)
).toThrow("unknown settle option");
let agent: Chat.Entry = { ...mina, author: { kind: "agent" } };
expect(() =>
applyInference(state, {
...valid,
source: source(agent, "resolution"),
}, agent)
).toThrow("human member required to settle");
expect(() =>
applyInference(state, {
...valid,
source: source(mina, "option"),
}, mina)
).toThrow("settle source role disagrees");
let decided = applyEvent(state, {
...base("card-decision", 2, "human"),
type: "decision.recorded",
text: "GitHub Apps",
optionId,
explicit: true,
});
expect(() => applyInference(decided, { ...valid, observedThreadVersion: 3 }, mina))
.toThrow("thread is not open for settling");
});
});
Loading
Loading