Skip to content
Open
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
1 change: 1 addition & 0 deletions .agents/skills/api-reference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The reference covers:
- MCP orchestration (`wait_for_subtasks`, `dispatch_task`, task inspection)
- MCP private incident backlog tools (`list_incident_queue`, `get_incident`, `claim_incident`, `resolve_incident`)
- Agent Sessions (`/api/workspaces/:id/agent-sessions/*`)
- Message-anchored chat comments (`/api/projects/:projectId/sessions/:sessionId/comments*`)
- Agent Settings (`/api/agent-settings/*`)
- Notifications (`/api/notifications/*`)
- Automation triggers (`/api/projects/:projectId/triggers/*`, `/api/webhooks/ingest`)
Expand Down
8 changes: 8 additions & 0 deletions .claude/skills/api-reference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,19 @@ user-invocable: false
- `GET /api/projects/:projectId/sessions/:sessionId/state` — Get lightweight ACP activity state for a chat session
- `GET /api/projects/:projectId/sessions/:sessionId/messages` — List persisted session messages (supports `roles`, `before`, `limit`, `compact`, `order=asc|desc`)
- `GET /api/projects/:projectId/sessions/:sessionId/messages/:messageId/tool-content` — Lazy-load stored tool content for compact messages
- `GET /api/projects/:projectId/sessions/:sessionId/comments` — List message-anchored comment threads (supports `messageId`, `status=open|sent|resolved`, `afterSequence`, `limit`)
- `POST /api/projects/:projectId/sessions/:sessionId/comments` — Create a message-anchored comment thread (`{ messageId, body, quote?, clientMutationId? }`)
- `POST /api/projects/:projectId/sessions/:sessionId/comments/:threadId/replies` — Append a comment reply (`{ body, clientMutationId? }`)
- `POST /api/projects/:projectId/sessions/:sessionId/comments/:threadId/send` — Mark a thread `sent` (`{ clientMutationId? }`)
- `POST /api/projects/:projectId/sessions/:sessionId/comments/:threadId/resolve` — Mark a thread `resolved` (`{ clientMutationId? }`)
- `POST /api/projects/:projectId/sessions/:sessionId/comments/:threadId/reopen` — Reopen a thread to `open` (`{ clientMutationId? }`)
- `POST /api/projects/:projectId/sessions/:sessionId/prompt` — Send a follow-up prompt to the active agent session
- `POST /api/projects/:projectId/sessions/:sessionId/attention/:markerId/resolve` — Validate, forward, and record one structured human-input answer (`{ answer }`)
- `POST /api/projects/:projectId/sessions/:sessionId/summarize` — Generate a session summary for conversation forking
- `POST /api/projects/:projectId/sessions/:sessionId/stop` — Stop a chat session

Comment threads are scoped to the ProjectData Durable Object addressed by `projectId`; route authorization requires project `task:read` for list and `task:write` for mutations, and the DO rejects missing sessions, missing messages, and cross-session message anchors. Mutations return `{ thread, idempotent }` or `{ thread, reply, idempotent }`; successful first writes use HTTP 201 for create/reply and 200 for status transitions. Project session WebSocket listeners receive `{ type: "comment.thread.changed", payload: { sessionId, thread, reason } }` with `reason` in `thread_created | reply_created | marked_sent | resolved | reopened`.

## Task Management (Project Scoped)

- `POST /api/projects/:projectId/tasks` — Create task
Expand Down
7 changes: 7 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,13 @@ INFOMANIAK_IP_POLL_INTERVAL_MS=3000
# Project Data Durable Object limits
# MAX_SESSIONS_PER_PROJECT=10000
# MAX_MESSAGES_PER_SESSION=100000
# COMMENT_BODY_MAX_LENGTH=8000 # Max characters per message-anchored comment or reply body
# COMMENT_QUOTE_MAX_LENGTH=2000 # Max characters preserved from quoted message text
# COMMENT_IDEMPOTENCY_KEY_MAX_LENGTH=200 # Max clientMutationId length for comment writes
# COMMENT_LIST_LIMIT_DEFAULT=100 # Default page size for comment thread lists
# COMMENT_LIST_LIMIT_MAX=500 # Max page size for comment thread lists
# COMMENT_THREADS_PER_SESSION_MAX=1000 # Max comment threads per chat session
# COMMENT_REPLIES_PER_THREAD_MAX=200 # Max replies per comment thread
# DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES=16384
# MESSAGE_SIZE_THRESHOLD=102400
# ACTIVITY_RETENTION_DAYS=90
Expand Down
91 changes: 91 additions & 0 deletions apps/api/src/durable-objects/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,97 @@ export const MIGRATIONS: Migration[] = [
`);
},
},
{
name: '032-message-comment-threads',
run: (sql) => {
sql.exec(`
CREATE TABLE comment_threads (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
anchor_kind TEXT NOT NULL DEFAULT 'message' CHECK (anchor_kind = 'message'),
message_id TEXT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
quote TEXT,
body TEXT NOT NULL,
author_type TEXT NOT NULL CHECK (author_type IN ('human', 'agent')),
author_id TEXT NOT NULL,
author_name TEXT,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'sent', 'resolved')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
sequence INTEGER NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
client_mutation_id TEXT,
client_mutation_fingerprint TEXT,
sent_at INTEGER,
sent_by_type TEXT CHECK (sent_by_type IS NULL OR sent_by_type IN ('human', 'agent')),
sent_by_id TEXT,
sent_by_name TEXT,
resolved_at INTEGER,
resolved_by_type TEXT CHECK (resolved_by_type IS NULL OR resolved_by_type IN ('human', 'agent')),
resolved_by_id TEXT,
resolved_by_name TEXT,
reopened_at INTEGER,
reopened_by_type TEXT CHECK (reopened_by_type IS NULL OR reopened_by_type IN ('human', 'agent')),
reopened_by_id TEXT,
reopened_by_name TEXT,
UNIQUE(session_id, client_mutation_id)
)
`);
sql.exec(`
CREATE INDEX idx_comment_threads_session_sequence
ON comment_threads(session_id, sequence)
`);
sql.exec(`
CREATE INDEX idx_comment_threads_message
ON comment_threads(session_id, message_id, sequence)
`);
sql.exec(`
CREATE INDEX idx_comment_threads_status
ON comment_threads(session_id, status, sequence)
`);

sql.exec(`
CREATE TABLE comment_replies (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,
session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
body TEXT NOT NULL,
author_type TEXT NOT NULL CHECK (author_type IN ('human', 'agent')),
author_id TEXT NOT NULL,
author_name TEXT,
created_at INTEGER NOT NULL,
sequence INTEGER NOT NULL,
client_mutation_id TEXT,
client_mutation_fingerprint TEXT,
UNIQUE(thread_id, client_mutation_id)
)
`);
sql.exec(`
CREATE INDEX idx_comment_replies_thread_sequence
ON comment_replies(thread_id, sequence)
`);
sql.exec(`
CREATE INDEX idx_comment_replies_session
ON comment_replies(session_id, thread_id)
`);

sql.exec(`
CREATE TABLE comment_status_mutations (
thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,
session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
client_mutation_id TEXT NOT NULL,
target_status TEXT NOT NULL CHECK (target_status IN ('open', 'sent', 'resolved')),
thread_version INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (thread_id, client_mutation_id)
)
`);
sql.exec(`
CREATE INDEX idx_comment_status_mutations_session
ON comment_status_mutations(session_id, created_at)
`);
},
},
];

/**
Expand Down
97 changes: 97 additions & 0 deletions apps/api/src/durable-objects/project-data/comment-contracts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type {
CommentAuthor,
CommentStatus,
MessageCommentReply,
MessageCommentThread,
} from '@simple-agent-manager/shared';

export type CommentActor = CommentAuthor;

export type CreateCommentThreadInput = {
sessionId: string;
messageId: string;
body: string;
quote?: string | null;
clientMutationId?: string | null;
actor: CommentActor;
};

export type CreateCommentReplyInput = {
sessionId: string;
threadId: string;
body: string;
clientMutationId?: string | null;
actor: CommentActor;
};

export type ListCommentThreadsInput = {
sessionId: string;
messageId?: string | null;
status?: CommentStatus | null;
afterSequence?: number | null;
limit?: number | null;
};

export type UpdateCommentStatusInput = {
sessionId: string;
threadId: string;
status: CommentStatus;
clientMutationId?: string | null;
actor: CommentActor;
};

export type CommentThreadMutationResult = {
thread: MessageCommentThread;
idempotent: boolean;
changed: boolean;
};

export type CommentReplyMutationResult = CommentThreadMutationResult & {
reply: MessageCommentReply;
};

export type ListCommentThreadsResult = {
threads: MessageCommentThread[];
hasMore: boolean;
};

export const COMMENT_NOT_FOUND = 'COMMENT_NOT_FOUND';
export const COMMENT_VALIDATION = 'COMMENT_VALIDATION';
export const COMMENT_IDEMPOTENCY_CONFLICT = 'COMMENT_IDEMPOTENCY_CONFLICT';
export const COMMENT_LIMIT_EXCEEDED = 'COMMENT_LIMIT_EXCEEDED';

export class CommentNotFoundError extends Error {
readonly code = COMMENT_NOT_FOUND;

constructor(readonly resource: 'Chat session' | 'Message' | 'Comment thread') {
super(`${resource} not found`);
this.name = 'CommentNotFoundError';
}
}

export class CommentValidationError extends Error {
readonly code = COMMENT_VALIDATION;

constructor(message: string) {
super(message);
this.name = 'CommentValidationError';
}
}

export class CommentIdempotencyConflictError extends Error {
readonly code = COMMENT_IDEMPOTENCY_CONFLICT;

constructor() {
super('clientMutationId already belongs to a different comment mutation');
this.name = 'CommentIdempotencyConflictError';
}
}

export class CommentLimitExceededError extends Error {
readonly code = COMMENT_LIMIT_EXCEEDED;

constructor(message: string) {
super(message);
this.name = 'CommentLimitExceededError';
}
}
Loading
Loading