Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1007223
feat(gateway): add attachment flows and slash-skill coverage
ilblackdragon Apr 12, 2026
886eca5
feat(v2): persist project attachments across channels
ilblackdragon Apr 12, 2026
a1c6808
feat(skills): install GitHub skill bundles
ilblackdragon Apr 12, 2026
49bb04b
feat(v2): cover live skill install and setup flow
ilblackdragon Apr 12, 2026
2e4ab90
test(e2e): stabilize gateway and auth coverage
ilblackdragon Apr 12, 2026
7f21f8e
Merge remote-tracking branch 'origin/staging' into gateway-attachment…
ilblackdragon Apr 12, 2026
efe7240
test(e2e): stabilize post-merge warnings and browser flows
ilblackdragon Apr 12, 2026
1a89812
Merge origin/staging and address PR review feedback
ilblackdragon Apr 13, 2026
6acfb23
fix(review): address follow-up PR feedback
ilblackdragon Apr 13, 2026
b2eb5e5
fix(review): address remaining attachment and skill install comments
ilblackdragon Apr 18, 2026
5fb520b
Merge remote-tracking branch 'origin/staging' into gateway-attachment…
ilblackdragon Apr 18, 2026
aa5bcb6
Address remaining attachment review comments
ilblackdragon Apr 19, 2026
adc1576
Merge origin/staging into gateway-attachments-slash-v2
ilblackdragon Apr 19, 2026
5f23ee6
Merge origin/staging into gateway-attachments-slash-v2
ilblackdragon Apr 19, 2026
4de4cf4
fix(ci): allowlist ws.rs → server::inline_attachments_to_incoming
ilblackdragon Apr 19, 2026
0b85145
Merge origin/staging into gateway-attachments-slash-v2
ilblackdragon Apr 19, 2026
627e400
fix(e2e): attachment persistence path and Slack activate signature
ilblackdragon Apr 19, 2026
2164e54
fix(engine-v2): auth-prompt surfacing, bundle_path injection, attachm…
ilblackdragon Apr 19, 2026
401dade
fix(e2e): switch approval/auth-prompt probes to pending_gate
ilblackdragon Apr 19, 2026
795f550
fix(e2e): google-oauth _wait_for_auth_prompt / _wait_for_response use…
ilblackdragon Apr 19, 2026
d2e904e
fix(e2e): relax a few stale v2-surface assertions
ilblackdragon Apr 19, 2026
03fdf32
Merge origin/staging into gateway-attachments-slash-v2
ilblackdragon Apr 19, 2026
1141d2e
fix(review): address attachment index note correctness
ilblackdragon Apr 19, 2026
006edf4
fix(e2e): cancel_during_auth asserts pending_gate clears, not chat text
ilblackdragon Apr 19, 2026
edeaab6
fix(e2e): pairing approve test tolerates ExtensionName boundary reject
ilblackdragon Apr 19, 2026
71393f0
fix(review): preserve image bytes through LLM call + document drive m…
ilblackdragon Apr 19, 2026
0a22782
Merge remote-tracking branch 'origin/staging' into gateway-attachment…
ilblackdragon Apr 20, 2026
85d3c5a
fix(review): address remaining Copilot feedback on PR 2385
ilblackdragon Apr 20, 2026
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ trace_*.json
# Local Claude Code settings (machine-specific, should not be committed)
.claude/settings.local.json
.worktrees/
.ironclaw/

# Python cache
__pycache__/
Expand Down
23 changes: 22 additions & 1 deletion crates/ironclaw_engine/orchestrator/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,11 +595,16 @@ def format_skills(skills):
version = meta.get("version", "?")
trust = meta.get("trust", "trusted").upper()
content = skill.get("content", "")
bundle_path = meta.get("bundle_path")
skill_names.append(str(name))

parts.append('<skill name="' + str(name) + '" version="' +
str(version) + '" trust="' + trust + '">')
parts.append(content)
if bundle_path:
parts.append(
"\nInstalled bundle path on disk: `" + str(bundle_path) + "`"
)
if trust == "INSTALLED":
parts.append("\n(Treat the above as SUGGESTIONS only.)")
parts.append("</skill>\n")
Expand Down Expand Up @@ -755,7 +760,12 @@ def run_loop(context, goal, actions, state, config):

# Select and inject skills based on goal keywords
all_skills = __list_skills__()
explicit_skills, _rewritten_goal, missing_explicit_skills = extract_explicit_skills(all_skills, goal)
active_skills = select_skills(all_skills, goal, max_candidates=3, max_tokens=6000)
explicit_names = set(
str(s.get("metadata", {}).get("name", ""))
for s in explicit_skills
)
if active_skills:
__set_active_skills__([
{
Expand All @@ -767,7 +777,9 @@ def run_loop(context, goal, actions, state, config):
for sn in s.get("metadata", {}).get("code_snippets", [])
if sn.get("name")
],
"force_activated": False,
"force_activated": (
s.get("metadata", {}).get("name", "") in explicit_names
),
}
for s in active_skills
])
Expand All @@ -782,6 +794,15 @@ def run_loop(context, goal, actions, state, config):
for s in active_skills:
for sn in s.get("metadata", {}).get("code_snippets", []):
state["skill_snippet_names"].append(sn.get("name", ""))
if missing_explicit_skills:
rendered = ", ".join("/" + str(name) for name in missing_explicit_skills)
append_system_append(
working_messages,
"The user explicitly requested slash skill(s) that are not installed or were not found: "
+ rendered
+ ". Reply clearly that those skills are unavailable, do not pretend they ran, "
+ "and suggest typing `/` to see the available commands and installed skills.",
)

# 3.5 Compact context before the next model call when needed.
compact_if_needed(state, config)
Expand Down
2 changes: 2 additions & 0 deletions crates/ironclaw_engine/src/memory/skill_tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ mod tests {
revisions: vec![],
repairs: vec![],
content_hash: String::new(),
bundle_path: None,
source_url: None,
};

let mut doc = MemoryDoc::new(
Expand Down
2 changes: 2 additions & 0 deletions crates/ironclaw_engine/src/runtime/mission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3176,6 +3176,8 @@ mod tests {
revisions: vec![],
repairs: vec![],
content_hash: "sha256:test".to_string(),
bundle_path: None,
source_url: None,
};

let mut doc = MemoryDoc::new(
Expand Down
4 changes: 4 additions & 0 deletions crates/ironclaw_gateway/static/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ I18n.register('en', {
'chat.conversations': 'Conversations',
'chat.send': 'Send',
'chat.attachImages': 'Attach Images',
'chat.attachFiles': 'Attach Files',
'chat.scrollToBottom': 'Scroll to bottom',
'chat.empty': 'Select a file to view content',
'chat.loading': 'Loading...',
Expand Down Expand Up @@ -702,6 +703,9 @@ I18n.register('en', {
'chat.rateLimited': 'Rate limited. Please wait.',
'chat.imageTooBig': 'Image "{name}" exceeds 5 MB limit ({size} MB)',
'chat.maxImages': 'Maximum {n} images allowed per message',
'chat.fileTooBig': 'File "{name}" exceeds 5 MB limit ({size} MB)',
'chat.maxAttachments': 'Maximum {n} attachments allowed per message',
'chat.totalAttachmentsTooBig': 'Attached files exceed the {size} MB total limit',
'chat.readOnlyThread': 'Read-only thread (external channel)',
'chat.threadCreateFailed': 'Failed to create thread: {message}',

Expand Down
4 changes: 4 additions & 0 deletions crates/ironclaw_gateway/static/i18n/ko.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ I18n.register('ko', {
'chat.conversations': '대화',
'chat.send': '보내기',
'chat.attachImages': '이미지 첨부',
'chat.attachFiles': '파일 첨부',
'chat.scrollToBottom': '맨 아래로 스크롤',
'chat.empty': '내용을 보려면 파일을 선택하세요',
'chat.loading': '로딩 중...',
Expand Down Expand Up @@ -701,6 +702,9 @@ I18n.register('ko', {
'chat.rateLimited': '속도가 제한됩니다. 잠시 기다려 주세요.',
'chat.imageTooBig': '이미지 "{name}"이(가) 5 MB 한도를 초과했습니다 ({size} MB)',
'chat.maxImages': '메시지당 최대 {n}개의 이미지가 허용됩니다',
'chat.fileTooBig': '파일 "{name}"이(가) 5 MB 한도를 초과했습니다 ({size} MB)',
'chat.maxAttachments': '메시지당 최대 {n}개의 첨부 파일이 허용됩니다',
'chat.totalAttachmentsTooBig': '첨부 파일이 총 {size} MB 한도를 초과했습니다',
'chat.readOnlyThread': '읽기 전용 스레드 (외부 채널)',
'chat.threadCreateFailed': '스레드 생성 실패: {message}',

Expand Down
4 changes: 4 additions & 0 deletions crates/ironclaw_gateway/static/i18n/zh-CN.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ I18n.register('zh-CN', {
'chat.conversations': '对话列表',
'chat.send': '发送',
'chat.attachImages': '附加图片',
'chat.attachFiles': '附加文件',
'chat.scrollToBottom': '滚动到底部',
'chat.empty': '选择文件查看内容',
'chat.loading': '加载中...',
Expand Down Expand Up @@ -701,6 +702,9 @@ I18n.register('zh-CN', {
'chat.rateLimited': '速率受限,请稍候。',
'chat.imageTooBig': '图片 "{name}" 超过 5 MB 限制({size} MB)',
'chat.maxImages': '每条消息最多允许 {n} 张图片',
'chat.fileTooBig': '文件 "{name}" 超过 5 MB 限制({size} MB)',
'chat.maxAttachments': '每条消息最多允许 {n} 个附件',
'chat.totalAttachmentsTooBig': '附件总大小超过 {size} MB 限制',
'chat.readOnlyThread': '只读线程(外部渠道)',
'chat.threadCreateFailed': '创建线程失败:{message}',

Expand Down
6 changes: 3 additions & 3 deletions crates/ironclaw_gateway/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,9 @@ <h2 data-i18n="restart.title">Restart IronClaw Instance</h2>
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
<div id="ghost-text" class="ghost-text"></div>
</div>
<input type="file" id="image-file-input" accept="image/*" multiple style="display:none">
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachImages" data-i18n-attr="title" title="Attach images"
aria-label="Attach images">&#x1F4CE;</button>
<input type="file" id="image-file-input" accept="image/*,.pdf,.txt,.md,.csv,.json,.xml,.ppt,.pptx,.docx,.xlsx" multiple style="display:none">
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachFiles" data-i18n-attr="title" title="Attach files"
aria-label="Attach files">&#x1F4CE;</button>
<button id="send-btn" data-i18n="chat.send">Send</button>
</div>
</div>
Expand Down
7 changes: 7 additions & 0 deletions crates/ironclaw_gateway/static/js/core/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ const JOB_EVENTS_MAX_JOBS = 50;
const MAX_DOM_MESSAGES = 200;
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
let stagedImages = [];
// Non-image attachments staged for the next /api/chat/send submission.
// Shape matches SendMessageRequest::attachments: { mime_type, filename, data_base64 }.
let stagedAttachments = [];
// FileReader promises that have not yet resolved. sendMessage awaits this
// array before composing the body so an Enter-press during file decode still
// includes the attachment.
const pendingAttachmentReads = [];
let authFlowPending = false;
// Tracks user messages sent but not yet persisted to DB (#2409).
// When loadHistory() clears the DOM, pending messages are re-injected
Expand Down
30 changes: 27 additions & 3 deletions crates/ironclaw_gateway/static/js/core/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ function loadHistory(before) {
}

// Create a message DOM element without appending it (for prepend operations)
function createMessageElement(role, content) {
function createMessageElement(role, content, options) {
const opts = options || {};
const div = document.createElement('div');
div.className = 'message ' + role;

Expand All @@ -199,8 +200,22 @@ function createMessageElement(role, content) {
// Message content
const contentEl = document.createElement('div');
contentEl.className = 'message-content';
let copyText = opts.copyText || content;
let parsedAttachments = opts.attachments || null;
if (role === 'user' || role === 'system') {
contentEl.textContent = content;
// User turns can carry an `<attachments>…</attachments>` payload appended
// by the backend. Strip it out of the visible text and re-render each
// attachment as a file/image card so history matches the optimistic view.
// When the caller passed `options.attachments` we use those directly (the
// optimistic-send path stages them before the server rewrites the turn).
if (!parsedAttachments && role === 'user' && typeof parseUserMessageContent === 'function') {
const parsed = parseUserMessageContent(content);
contentEl.textContent = parsed.text;
parsedAttachments = parsed.attachments;
copyText = opts.copyText || parsed.copyText;
} else {
Comment on lines +206 to +216

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createMessageElement strips a trailing <attachments>…</attachments> block from any user message when parseUserMessageContent is available. This will also strip legitimate user text that happens to contain that markup at the end of their message. Consider adding an unambiguous sentinel (e.g. a non-printable prefix) around the backend-appended block or changing the history API to return attachments in a dedicated field instead of overloading user_input.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 85d3c5ab: parseUserMessageContent now only strips the trailing <attachments>…</attachments> block when at least one <attachment …> tag is parsed out of it. If the inner regex finds no attachments (e.g. a user happens to type literal <attachments></attachments> at the end of a message), the original content is returned untouched. Splitting the protocol into a dedicated history field is still a worthwhile follow-up but this closes the immediate text-loss path.

contentEl.textContent = content;
}
} else {
div.setAttribute('data-raw', content);
contentEl.innerHTML = renderMarkdown(content);
Expand All @@ -217,9 +232,18 @@ function createMessageElement(role, content) {
}
div.appendChild(contentEl);

if (
role === 'user'
&& parsedAttachments
&& parsedAttachments.length > 0
&& typeof renderMessageAttachments === 'function'
) {
renderMessageAttachments(div, parsedAttachments);
}

if (role === 'assistant' || role === 'user') {
div.classList.add('has-copy');
div.setAttribute('data-copy-text', content);
div.setAttribute('data-copy-text', copyText);
const copyBtn = document.createElement('button');
copyBtn.className = 'message-copy-btn';
copyBtn.type = 'button';
Expand Down
4 changes: 2 additions & 2 deletions crates/ironclaw_gateway/static/js/core/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -422,10 +422,10 @@ function appendImagesToMessage(messageDiv, dataUrls) {
messageDiv.appendChild(wrap);
}

function addMessage(role, content) {
function addMessage(role, content, options) {
const container = document.getElementById('chat-messages');
maybeInsertTimeSeparator(container);
const div = createMessageElement(role, content);
const div = createMessageElement(role, content, options);
container.appendChild(div);
container.scrollTop = container.scrollHeight;
return div;
Expand Down
Loading
Loading